diff --git a/package.json b/package.json index 9a0ceb8..f2a7f08 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "verify:battle-data": "node scripts/verify-battle-scenario-data.mjs", "verify:campaign-data": "node scripts/verify-campaign-flow-data.mjs", "verify:campaign-save": "node scripts/verify-campaign-save-normalization.mjs", + "verify:campaign-completion": "node scripts/verify-campaign-completion.mjs", "verify:growth-consistency": "node scripts/verify-growth-consistency.mjs", "verify:battle-save": "node scripts/verify-battle-save-normalization.mjs", "verify:battle-usables": "node scripts/verify-battle-usables.mjs", @@ -40,6 +41,9 @@ "verify:story-images": "node scripts/verify-story-image-asset-data.mjs", "verify:visual-assets": "node scripts/verify-visual-asset-data.mjs", "verify:unit-sprites": "node scripts/verify-unit-sprite-asset-data.mjs", + "verify:unit-action-assets": "python scripts/optimize-unit-action-assets.py && node scripts/verify-battle-action-asset-budgets.mjs", + "verify:unit-action-assets:dist": "node scripts/verify-battle-action-asset-budgets.mjs --dist", + "verify:loader-lifecycle": "node scripts/verify-loader-lifecycle.mjs", "verify:battle-map-assets": "node scripts/verify-battle-map-asset-data.mjs", "verify:audio-assets": "node scripts/verify-audio-asset-data.mjs", "verify:desktop-viewport": "node scripts/verify-desktop-browser-viewport.mjs", @@ -47,12 +51,14 @@ "verify:flow": "node scripts/verify-flow.mjs", "verify:save-flow": "node scripts/verify-save-retry-flow.mjs", "verify:release": "node scripts/verify-release-candidate.mjs", - "verify:local-release": "pnpm run verify:static-data && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:release && pnpm run verify:performance", + "verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:release && pnpm run verify:performance", + "verify:1.0": "pnpm run verify:local-release && pnpm run verify:flow && pnpm run qa:campaign-complete && pnpm run qa:battle-maps:webgl", "verify:public-deploy": "node scripts/verify-public-deploy.mjs", "qa:representative": "node scripts/qa-representative-battles.mjs", "qa:battle-maps:webgl": "node scripts/qa-representative-battles.mjs --set=campaign --battles=1,9,18,22,33,40,46,54,59,61,64,66 --renderer=webgl --report=dist/qa-battle-maps-webgl.json", "qa:smoke": "node scripts/qa-representative-battles.mjs --set=smoke", "qa:early": "node scripts/qa-representative-battles.mjs --set=campaign --battles=1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66", + "qa:campaign-complete": "node scripts/qa-representative-battles.mjs --set=campaign --cumulative=1 --completion=1 --renderer=canvas --report=dist/qa-campaign-complete.json", "qa:early:repeat": "node scripts/qa-repeat.mjs --set=campaign --battles=1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66", "deploy:nas": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/deploy-nas.ps1 -Build", "deploy:nas:dist": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/deploy-nas.ps1", diff --git a/scripts/measure-performance.mjs b/scripts/measure-performance.mjs index 0c88665..d6b17c3 100644 --- a/scripts/measure-performance.mjs +++ b/scripts/measure-performance.mjs @@ -410,7 +410,7 @@ async function verifyCombatAssetStallFallback(browserInstance, baseUrl, kind) { const url = route.request().url(); const fileName = decodeURIComponent(url.split('/').pop()?.split('?')[0] ?? ''); const shouldStall = kind === 'action' - ? /^unit-.*-actions-[^.]+\.png$/i.test(fileName) + ? /^unit-.*-actions-[^.]+\.(?:png|webp)$/i.test(fileName) : /^(?:liu-bei|guan-yu|zhang-fei)-[^.]+\.webp$/i.test(fileName); const isSelectedStall = shouldStall && (!stalledUrl || stalledUrl === url); if (isSelectedStall) { @@ -578,7 +578,7 @@ function extensionOf(name) { function actionSheetFiles(entries) { return entries .map((entry) => decodeURIComponent(entry.name.split('/').pop() ?? entry.name)) - .filter((file) => /^unit-.*-actions(?:-[^.]+)?\.png(?:[?#]|$)/i.test(file)); + .filter((file) => /^unit-.*-actions(?:-[^.]+)?\.(?:png|webp)(?:[?#]|$)/i.test(file)); } function bytesToKb(bytes) { diff --git a/scripts/optimize-unit-action-assets.py b/scripts/optimize-unit-action-assets.py new file mode 100644 index 0000000..95b6a8e --- /dev/null +++ b/scripts/optimize-unit-action-assets.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Build and verify the approved unit base/action WebP derivatives.""" + +from __future__ import annotations + +import argparse +from concurrent.futures import ProcessPoolExecutor +import json +import os +from pathlib import Path + +from PIL import Image, ImageChops, ImageStat, features + + +ROOT = Path(__file__).resolve().parents[1] +POLICY_PATH = ROOT / "src/game/data/unitActionAssetPolicy.json" +UNIT_DIR = ROOT / "src/assets/images/units" + + +def load_policy() -> dict: + return json.loads(POLICY_PATH.read_text(encoding="utf-8")) + + +def optimized_keys(policy: dict, group: str) -> list[str]: + if policy.get("optimizeAll") is True: + if group == "action": + keys = sorted(path.name.removesuffix("-actions.png") for path in UNIT_DIR.glob("unit-*-actions.png")) + else: + keys = sorted( + path.name.removesuffix(".png") + for path in UNIT_DIR.glob("unit-*.png") + if not path.name.endswith("-actions.png") + ) + else: + keys = list(policy[f"optimized{group.title()}Keys"]) + + expected_count = int(policy.get(f"expected{group.title()}AssetCount", len(keys))) + if len(keys) != expected_count: + raise RuntimeError(f"expected {expected_count} {group} sources, found {len(keys)}") + return keys + + +def resize_sheet(source: Image.Image, source_size: int, target_size: int, columns: int, rows: int) -> Image.Image: + expected_size = (columns * source_size, rows * source_size) + if source.size != expected_size: + raise ValueError(f"expected {expected_size}, got {source.size}") + + target = Image.new("RGBA", (columns * target_size, rows * target_size)) + for row in range(rows): + for column in range(columns): + left = column * source_size + top = row * source_size + frame = source.crop((left, top, left + source_size, top + source_size)) + frame = frame.resize((target_size, target_size), Image.Resampling.LANCZOS) + target.paste(frame, (column * target_size, row * target_size)) + return target + + +def frame_quality(reference: Image.Image, actual: Image.Image, frame_size: int, columns: int, rows: int) -> dict: + rgb_error_sum = [0.0, 0.0, 0.0] + composite_error_sum = [0.0, 0.0, 0.0] + visible_pixels = 0 + alpha_error_max = 0 + + for row in range(rows): + for column in range(columns): + box = ( + column * frame_size, + row * frame_size, + (column + 1) * frame_size, + (row + 1) * frame_size, + ) + expected_frame = reference.crop(box) + actual_frame = actual.crop(box) + alpha_diff = ImageChops.difference(expected_frame.getchannel("A"), actual_frame.getchannel("A")) + alpha_error_max = max(alpha_error_max, alpha_diff.getextrema()[1]) + + visible_mask = expected_frame.getchannel("A").point(lambda alpha: 255 if alpha > 8 else 0) + frame_pixels = ImageStat.Stat(visible_mask).sum[0] / 255 + if frame_pixels <= 0: + continue + + rgb_mean = ImageStat.Stat(ImageChops.difference(expected_frame, actual_frame), mask=visible_mask).mean[:3] + for channel in range(3): + rgb_error_sum[channel] += rgb_mean[channel] * frame_pixels + + for background in ((28, 32, 36, 255), (232, 226, 210, 255)): + expected_composite = Image.new("RGBA", expected_frame.size, background) + expected_composite.alpha_composite(expected_frame) + actual_composite = Image.new("RGBA", actual_frame.size, background) + actual_composite.alpha_composite(actual_frame) + composite_mean = ImageStat.Stat( + ImageChops.difference(expected_composite.convert("RGB"), actual_composite.convert("RGB")) + ).mean + for channel in range(3): + composite_error_sum[channel] += composite_mean[channel] * frame_pixels + + visible_pixels += frame_pixels + + divisor = max(1, visible_pixels) + return { + "visibleMeanAbsRgb": [round(value / divisor, 3) for value in rgb_error_sum], + "compositeMeanAbsRgb": [round(value / (divisor * 2), 3) for value in composite_error_sum], + "alphaErrorMax": alpha_error_max, + } + + +def inspect_asset(task: tuple[str, dict, str, bool]) -> dict: + key, policy, group, write = task + source_size = int(policy["sourceFrameSize"]) + target_size = int(policy["optimizedFrameSize"]) + columns = int(policy[f"{group}Columns"]) + rows = int(policy[f"{group}Rows"]) + suffix = "-actions" if group == "action" else "" + source_path = UNIT_DIR / f"{key}{suffix}.png" + target_path = UNIT_DIR / f"{key}{suffix}.webp" + + source = Image.open(source_path).convert("RGBA") + resized = resize_sheet(source, source_size, target_size, columns, rows) + if write: + target_path.parent.mkdir(parents=True, exist_ok=True) + resized.save(target_path, "WEBP", lossless=True, method=int(policy["method"]), exact=True) + if not target_path.exists(): + raise FileNotFoundError(f"missing optimized {group} sheet: {target_path}") + + actual = Image.open(target_path).convert("RGBA") + if actual.size != resized.size: + raise ValueError(f"{target_path.name}: expected {resized.size}, got {actual.size}") + + source_bytes = source_path.stat().st_size + target_bytes = target_path.stat().st_size + return { + "group": group, + "key": key, + "source": source_path.name, + "optimized": target_path.name, + "sourceBytes": source_bytes, + "optimizedBytes": target_bytes, + "sourceDecodedBytes": source.width * source.height * 4, + "optimizedDecodedBytes": actual.width * actual.height * 4, + "encodedRatio": round(target_bytes / source_bytes, 4), + **frame_quality(resized, actual, target_size, columns, rows), + } + + +def summarize_group(group: str, rows: list[dict]) -> dict: + group_rows = [row for row in rows if row["group"] == group] + source_bytes = sum(row["sourceBytes"] for row in group_rows) + optimized_bytes = sum(row["optimizedBytes"] for row in group_rows) + source_decoded = sum(row["sourceDecodedBytes"] for row in group_rows) + optimized_decoded = sum(row["optimizedDecodedBytes"] for row in group_rows) + if optimized_bytes / source_bytes > 0.55: + raise RuntimeError(f"optimized {group} sheets did not meet the 45% encoded-size reduction budget") + if optimized_decoded / source_decoded > 0.38: + raise RuntimeError(f"optimized {group} sheets did not meet the 62% decoded-memory reduction budget") + return { + "assetCount": len(group_rows), + "sourceMiB": round(source_bytes / 1024 / 1024, 1), + "optimizedMiB": round(optimized_bytes / 1024 / 1024, 1), + "encodedReductionPercent": round((1 - optimized_bytes / source_bytes) * 100, 1), + "sourceDecodedMiB": round(source_decoded / 1024 / 1024, 1), + "optimizedDecodedMiB": round(optimized_decoded / 1024 / 1024, 1), + "decodedReductionPercent": round((1 - optimized_decoded / source_decoded) * 100, 1), + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--write", action="store_true", help="write the approved WebP derivatives before checking them") + parser.add_argument("--jobs", type=int, default=min(4, os.cpu_count() or 1), help="parallel conversion/check workers") + parser.add_argument("--group", choices=("all", "base", "action"), default="all") + args = parser.parse_args() + policy = load_policy() + + if not features.check("webp"): + raise RuntimeError("Pillow was built without WebP support") + if policy["format"] != "webp" or policy["lossless"] is not True: + raise ValueError("approved unit derivatives must use lossless WebP") + + groups = ["base", "action"] if args.group == "all" else [args.group] + tasks = [ + (key, policy, group, args.write) + for group in groups + for key in optimized_keys(policy, group) + ] + jobs = max(1, min(args.jobs, len(tasks))) + if jobs == 1: + rows = [inspect_asset(task) for task in tasks] + else: + with ProcessPoolExecutor(max_workers=jobs) as executor: + rows = list(executor.map(inspect_asset, tasks)) + + max_visible_error = max(max(row["visibleMeanAbsRgb"]) for row in rows) + max_composite_error = max(max(row["compositeMeanAbsRgb"]) for row in rows) + max_alpha_error = max(row["alphaErrorMax"] for row in rows) + if max_visible_error != 0 or max_composite_error != 0 or max_alpha_error != 0: + raise RuntimeError( + "optimized unit-sheet quality budget failed: " + f"visible={max_visible_error}, composite={max_composite_error}, alpha={max_alpha_error}" + ) + + report = { + "policyVersion": policy["version"], + "sourceFrameSize": policy["sourceFrameSize"], + "optimizedFrameSize": policy["optimizedFrameSize"], + "groups": {group: summarize_group(group, rows) for group in groups}, + "quality": { + "maxVisibleMeanAbsRgb": max_visible_error, + "maxCompositeMeanAbsRgb": max_composite_error, + "maxAlphaError": max_alpha_error, + }, + "largestOptimizedAssets": sorted(rows, key=lambda row: row["optimizedBytes"], reverse=True)[:8], + } + print(json.dumps(report, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/qa-representative-battles.mjs b/scripts/qa-representative-battles.mjs index e674626..08d3b87 100644 --- a/scripts/qa-representative-battles.mjs +++ b/scripts/qa-representative-battles.mjs @@ -1,6 +1,6 @@ import { spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; import { chromium } from 'playwright'; import { desktopBrowserContextOptions, desktopBrowserViewport } from './desktop-browser-viewport.mjs'; @@ -11,7 +11,8 @@ const qaRenderer = cliOptions.renderer ?? process.env.QA_RENDERER ?? 'canvas'; const targetUrl = withRenderer(process.env.QA_URL ?? `http://127.0.0.1:${defaultQaPort}/`, qaRenderer); const headless = process.env.QA_HEADLESS !== '0'; const maxRounds = Number(process.env.QA_MAX_ROUNDS ?? 80); -const cumulativeMode = process.env.QA_CUMULATIVE === '1'; +const cumulativeMode = cliOptions.cumulative === '1' || process.env.QA_CUMULATIVE === '1'; +const completionMode = cliOptions.completion === '1' || process.env.QA_COMPLETION === '1'; const qaReportPath = cliOptions.report ?? process.env.QA_REPORT_PATH ?? 'dist/qa-representative-battles.json'; const campaignStorageKey = 'heros-web:campaign-state'; const campaignSlotStorageKey = 'heros-web:campaign-state:slot-1'; @@ -1083,6 +1084,8 @@ const allCampaignBattles = [ ...nanzhongFinalCampaignBattles, ...northernCampaignBattles ]; +const campaignRouteChain = await readCampaignRouteChain(); +const campaignRouteByBattleId = new Map(campaignRouteChain.map((route) => [route.battleId, route])); const allRepresentativeBattles = [ { no: 1, id: 'first-battle-zhuo-commandery', selected: [], targets: ['rebel-leader'], protected: ['liu-bei'] }, @@ -1175,9 +1178,26 @@ const representativeBattles = ? qaBattleSet.filter((battle) => requestedBattles.has(String(battle.no)) || requestedBattles.has(battle.id)) : qaBattleSet; +if (completionMode) { + if (!cumulativeMode || qaSetName !== 'campaign') { + throw new Error('Campaign completion mode requires QA_SET=campaign with cumulative mode enabled.'); + } + if (resumeCampaignSnapshot) { + throw new Error('Campaign completion mode must start from an empty save and cannot resume a campaign snapshot.'); + } + const expectedIds = campaignRouteChain.map((route) => route.battleId); + const requestedIds = representativeBattles.map((battle) => battle.id); + if (JSON.stringify(requestedIds) !== JSON.stringify(expectedIds)) { + throw new Error(`Campaign completion battle order differs from campaign routing: ${JSON.stringify({ requestedIds, expectedIds })}`); + } +} + let serverProcess; try { + if (qaReportPath) { + await rm(qaReportPath, { force: true }); + } serverProcess = await ensureLocalServer(targetUrl); const browser = await chromium.launch({ headless }); @@ -1187,13 +1207,23 @@ try { for (const battle of representativeBattles) { console.log(`QA battle ${battle.no}: ${battle.id}`); - const preparation = cumulativeMode ? await setupCumulativeBattle(page, battle, results.length === 0) : await setupBattle(page, battle); + const route = campaignRouteByBattleId.get(battle.id); + const preparation = cumulativeMode + ? await setupCumulativeBattle(page, battle, results.length === 0, route) + : await setupBattle(page, battle); const result = await playBattleWithoutDebugVictory(page, battle); + console.log(`QA battle outcome ${battle.no}: ${result.outcome ?? 'unresolved'} turn=${result.finalTurn}`); if (preparation) { result.campSuppliesUsed = preparation.suppliesUsed; } if (cumulativeMode) { result.campaign = await readCampaignSummary(page); + if (completionMode && result.outcome === 'victory') { + assertCumulativeBattleSettlement(result.campaign, battle, route); + if (battle.no < representativeBattles.length) { + result.intermissionCamp = await enterCampaignCampFromBattleResult(page, battle, route); + } + } if (campaignSnapshotPath) { await writeCampaignSnapshot(page, campaignSnapshotPath); } @@ -1234,8 +1264,6 @@ try { ); printQaSummary(results, failed, missedObjectives); - await writeQaReport(qaReportPath, results, failed); - if (failed.length > 0) { console.dir( failed.map((result) => ({ @@ -1260,6 +1288,13 @@ try { ); } + if (completionMode) { + const finalCamp = await enterFinalCampFromBattleResult(page); + await assertCompleteCampaignSave(page, results, finalCamp); + } + + await writeQaReport(qaReportPath, results, failed); + console.log(`Representative battle QA passed without debug victory for ${results.length} battles at ${targetUrl}`); } finally { await browser.close(); @@ -1287,6 +1322,7 @@ async function setupBattle(page, battle) { inventory: {}, selectedSortieUnitIds: selected, reserveTrainingFocus: 'balanced', + completedTutorialIds: ['first-battle-basic-controls'], completedCampDialogues: [], completedCampVisits: [], battleHistory: {} @@ -1316,16 +1352,34 @@ async function setupBattle(page, battle) { await normalizeRepresentativeBattleUnits(page, battle); } -async function setupCumulativeBattle(page, battle, firstBattle) { +async function setupCumulativeBattle(page, battle, firstBattle, route) { const initialSnapshot = - firstBattle && resumeCampaignSnapshot && campaignSnapshotPath && existsSync(campaignSnapshotPath) + !completionMode && firstBattle && resumeCampaignSnapshot && campaignSnapshotPath && existsSync(campaignSnapshotPath) ? await readFile(campaignSnapshotPath, 'utf8') : undefined; + const expectedPreviousRoutes = completionMode ? campaignRouteChain.slice(0, battle.no - 1) : []; + const expectedBeforeStep = completionMode + ? battle.no === 1 + ? route?.battleStep + : campaignRouteChain[battle.no - 2]?.campStep + : undefined; await gotoTargetPage(page); await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined && window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 }); await assertRequestedRenderer(page); const preparation = await page.evaluate( - ({ firstBattle, initialSnapshot, labels, protectedUnitIds, selected, slotKey, storageKey }) => { + ({ + firstBattle, + initialSnapshot, + labels, + protectedUnitIds, + selected, + slotKey, + storageKey, + strictCompletion, + expectedPreviousBattleIds, + expectedBeforeStep, + battleStep + }) => { const now = new Date().toISOString(); const defaultState = { version: 1, @@ -1338,6 +1392,7 @@ async function setupCumulativeBattle(page, battle, firstBattle) { inventory: {}, selectedSortieUnitIds: selected, reserveTrainingFocus: 'balanced', + completedTutorialIds: ['first-battle-basic-controls'], completedCampDialogues: [], completedCampVisits: [], battleHistory: {} @@ -1349,6 +1404,31 @@ async function setupCumulativeBattle(page, battle, firstBattle) { const rawState = initialSnapshot ?? (firstBattle ? undefined : window.localStorage.getItem(slotKey) ?? window.localStorage.getItem(storageKey)); const state = rawState ? JSON.parse(rawState) : defaultState; + state.completedTutorialIds = [ + ...new Set([ + ...(Array.isArray(state.completedTutorialIds) ? state.completedTutorialIds : []), + 'first-battle-basic-controls' + ]) + ]; + if (strictCompletion) { + const historyIds = Object.keys(state.battleHistory ?? {}); + const expectedLatestBattleId = expectedPreviousBattleIds.at(-1); + if ( + JSON.stringify(historyIds) !== JSON.stringify(expectedPreviousBattleIds) || + state.step !== expectedBeforeStep || + (state.latestBattleId ?? undefined) !== expectedLatestBattleId + ) { + throw new Error(`Pre-battle campaign state is out of order: ${JSON.stringify({ + step: state.step, + expectedBeforeStep, + historyIds, + expectedPreviousBattleIds, + latestBattleId: state.latestBattleId, + expectedLatestBattleId + })}`); + } + state.step = battleStep; + } const suppliesUsed = { bean: 0, salve: 0, wine: 0 }; state.updatedAt = now; state.activeSaveSlot = 1; @@ -1430,7 +1510,11 @@ async function setupCumulativeBattle(page, battle, firstBattle) { protectedUnitIds: battle.protected, selected: battle.selected, slotKey: campaignSlotStorageKey, - storageKey: campaignStorageKey + storageKey: campaignStorageKey, + strictCompletion: completionMode, + expectedPreviousBattleIds: expectedPreviousRoutes.map((candidate) => candidate.battleId), + expectedBeforeStep, + battleStep: route?.battleStep } ); @@ -1642,6 +1726,7 @@ async function readCampaignSummary(page) { step: state.step, latestBattleId: state.latestBattleId, battleCount: Object.keys(state.battleHistory ?? {}).length, + historyIds: Object.keys(state.battleHistory ?? {}), gold: state.gold ?? 0, supplies: { bean: state.inventory?.[labels.bean] ?? 0, @@ -1663,6 +1748,287 @@ async function readCampaignSummary(page) { ); } +async function enterCampaignCampFromBattleResult(page, battle, route) { + console.log(`QA intermission ${battle.no}: waiting for the battle result controls`); + await page.waitForFunction(() => { + const settlement = window.__HEROS_DEBUG__?.battle()?.resultSettlement; + return settlement?.cta?.interactive === true && settlement?.cta?.bounds; + }, undefined, { timeout: 90000 }); + + let settlement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.resultSettlement); + if (settlement?.status !== 'complete') { + const settlePoint = await readBattleResultCtaPoint(page); + await page.mouse.click(settlePoint.x, settlePoint.y); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.battle()?.resultSettlement?.status === 'complete', + undefined, + { timeout: 30000 } + ); + settlement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.resultSettlement); + } + + const expectedDestination = battle.no === 1 ? 'victory-story' : 'camp'; + if (settlement?.cta?.destination !== expectedDestination) { + throw new Error( + `Battle ${battle.no} result continuation mismatch: ${JSON.stringify({ settlement, expectedDestination })}` + ); + } + const continuePoint = await readBattleResultCtaPoint(page); + await page.mouse.click(continuePoint.x, continuePoint.y); + console.log(`QA intermission ${battle.no}: continuing to ${expectedDestination}`); + + if (expectedDestination === 'victory-story') { + await page.waitForFunction(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + return activeScenes.includes('StoryScene') || activeScenes.includes('CampScene'); + }, undefined, { timeout: 30000 }); + for (let index = 0; index < 24; index += 1) { + const reachedCamp = await page.evaluate(() => ( + window.__HEROS_DEBUG__?.activeScenes()?.includes('CampScene') ?? false + )); + if (reachedCamp) { + break; + } + await page.evaluate(() => window.__HEROS_DEBUG__?.scene('StoryScene')?.advance?.()); + await page.waitForTimeout(260); + } + } + + console.log(`QA intermission ${battle.no}: waiting for the reward camp`); + await page.waitForFunction( + ({ battleId, campStep }) => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + const camp = window.__HEROS_DEBUG__?.camp?.(); + const reward = camp?.victoryRewardAcknowledgement; + return ( + activeScenes.includes('CampScene') && + !activeScenes.includes('BattleScene') && + camp?.scene === 'CampScene' && + camp?.campBattleId === battleId && + camp?.campaign?.step === campStep && + reward?.visible === true && + reward?.battleId === battleId && + reward?.cards?.length === 6 && + reward.cards.every((card) => card.bounds && (card.actionId === null || card.interactive === true)) && + reward?.actions?.length === 4 && + reward.actions.every((action) => action.bounds && action.interactive === true) + ); + }, + { battleId: battle.id, campStep: route?.campStep }, + { timeout: 90000 } + ); + console.log(`QA intermission ${battle.no}: reward camp ready`); + + return page.evaluate(() => { + const camp = window.__HEROS_DEBUG__?.camp?.(); + return { + battleId: camp?.campBattleId ?? null, + step: camp?.campaign?.step ?? null, + rewardBattleId: camp?.victoryRewardAcknowledgement?.battleId ?? null, + rewardCardIds: camp?.victoryRewardAcknowledgement?.cards?.map((card) => card.id) ?? [], + rewardActionIds: camp?.victoryRewardAcknowledgement?.actions?.map((action) => action.id) ?? [] + }; + }); +} + +async function enterFinalCampFromBattleResult(page) { + await page.waitForFunction(() => { + const settlement = window.__HEROS_DEBUG__?.battle()?.resultSettlement; + return settlement?.cta?.interactive === true && settlement?.cta?.bounds; + }, undefined, { timeout: 90000 }); + + let settlement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.resultSettlement); + if (settlement?.status !== 'complete') { + const settlePoint = await readBattleResultCtaPoint(page); + await page.mouse.click(settlePoint.x, settlePoint.y); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.battle()?.resultSettlement?.status === 'complete', + undefined, + { timeout: 30000 } + ); + settlement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.resultSettlement); + } + if (settlement?.cta?.destination !== 'camp') { + throw new Error(`Final victory must continue to the final camp before the epilogue: ${JSON.stringify(settlement)}`); + } + + const continuePoint = await readBattleResultCtaPoint(page); + await page.mouse.click(continuePoint.x, continuePoint.y); + await page.waitForFunction(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + const camp = window.__HEROS_DEBUG__?.camp?.(); + return ( + activeScenes.includes('CampScene') && + !activeScenes.includes('BattleScene') && + camp?.scene === 'CampScene' && + camp?.campBattleId === 'sixty-sixth-battle-wuzhang-final' && + camp?.campSkin?.campaignStep === 'sixty-sixth-camp' && + camp?.victoryRewardAcknowledgement?.visible === true && + camp?.victoryRewardAcknowledgement?.battleId === 'sixty-sixth-battle-wuzhang-final' && + camp?.victoryRewardAcknowledgement?.cards?.length === 6 && + camp.victoryRewardAcknowledgement.cards.every((card) => card.bounds && (card.actionId === null || card.interactive === true)) && + camp?.victoryRewardAcknowledgement?.actions?.length === 4 && + camp.victoryRewardAcknowledgement.actions.every((action) => action.bounds && action.interactive === true) + ); + }, undefined, { timeout: 90000 }); + + const finalCamp = await page.evaluate(() => ({ + activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [], + camp: window.__HEROS_DEBUG__?.camp?.() ?? null + })); + const closeAction = finalCamp.camp?.victoryRewardAcknowledgement?.actions?.find((action) => action.id === 'close'); + if (!closeAction?.bounds) { + throw new Error(`Final reward panel did not expose its close action: ${JSON.stringify(finalCamp)}`); + } + const closePoint = await readSceneBoundsPoint(page, 'CampScene', closeAction.bounds); + await page.mouse.click(closePoint.x, closePoint.y); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement?.visible === false, + undefined, + { timeout: 30000 } + ); + return { + ...finalCamp, + rewardCloseVerified: true, + campAfterClose: await page.evaluate(() => window.__HEROS_DEBUG__?.camp?.() ?? null) + }; +} + +async function readBattleResultCtaPoint(page) { + const point = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const canvas = document.querySelector('canvas'); + const cta = window.__HEROS_DEBUG__?.battle()?.resultSettlement?.cta; + const bounds = cta?.bounds; + if (!scene || !canvas || !bounds || cta?.interactive !== true) { + return null; + } + const canvasBounds = canvas.getBoundingClientRect(); + return { + x: canvasBounds.left + (bounds.x + bounds.width / 2) * canvasBounds.width / scene.scale.width, + y: canvasBounds.top + (bounds.y + bounds.height / 2) * canvasBounds.height / scene.scale.height + }; + }); + if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) { + throw new Error(`Could not resolve the battle result CTA point: ${JSON.stringify(point)}`); + } + return point; +} + +async function readSceneBoundsPoint(page, sceneKey, bounds) { + 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 || !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(`Could not resolve ${sceneKey} bounds: ${JSON.stringify(bounds)}`); + } + return point; +} + +function assertCumulativeBattleSettlement(campaign, battle, route) { + const expectedRoutes = campaignRouteChain.slice(0, battle.no); + const expectedHistoryIds = expectedRoutes.map((candidate) => candidate.battleId); + if ( + !route || + route.battleId !== battle.id || + campaign?.step !== route.campStep || + campaign?.latestBattleId !== battle.id || + campaign?.battleCount !== battle.no || + JSON.stringify(campaign?.historyIds ?? []) !== JSON.stringify(expectedHistoryIds) + ) { + throw new Error(`Battle ${battle.no} settlement did not persist in campaign order: ${JSON.stringify({ + battleId: battle.id, + route, + campaign, + expectedHistoryIds + })}`); + } +} + +async function assertCompleteCampaignSave(page, results, finalCamp) { + if (!cumulativeMode || qaSetName !== 'campaign' || representativeBattles.length !== allCampaignBattles.length) { + throw new Error('Campaign completion mode requires the full campaign battle set with cumulative mode enabled.'); + } + + const snapshot = await page.evaluate( + ({ slotKey, storageKey }) => { + const slotRaw = window.localStorage.getItem(slotKey); + const baseRaw = window.localStorage.getItem(storageKey); + return { + slotRaw, + baseRaw, + state: slotRaw ? JSON.parse(slotRaw) : baseRaw ? JSON.parse(baseRaw) : undefined + }; + }, + { slotKey: campaignSlotStorageKey, storageKey: campaignStorageKey } + ); + const expectedIds = allCampaignBattles.map((battle) => battle.id); + const history = snapshot.state?.battleHistory ?? {}; + const missingIds = expectedIds.filter((battleId) => history[battleId]?.outcome !== 'victory'); + const invalidIntermissions = results.slice(0, -1).filter((result, index) => { + const route = campaignRouteChain[index]; + return ( + result.intermissionCamp?.battleId !== result.id || + result.intermissionCamp?.step !== route?.campStep || + result.intermissionCamp?.rewardBattleId !== result.id || + result.intermissionCamp?.rewardCardIds?.length !== 6 || + result.intermissionCamp?.rewardActionIds?.length !== 4 + ); + }); + const finalReward = finalCamp?.camp?.victoryRewardAcknowledgement; + + if ( + results.length !== expectedIds.length || + results.some((result) => result.outcome !== 'victory') || + Object.keys(history).length !== expectedIds.length || + missingIds.length > 0 || + invalidIntermissions.length > 0 || + snapshot.state?.latestBattleId !== expectedIds.at(-1) || + snapshot.state?.step !== 'sixty-sixth-camp' || + !snapshot.state?.firstBattleReport || + snapshot.state.firstBattleReport.battleId !== expectedIds.at(-1) || + snapshot.slotRaw !== snapshot.baseRaw || + !finalCamp?.activeScenes?.includes('CampScene') || + finalCamp?.camp?.scene !== 'CampScene' || + finalCamp?.camp?.campBattleId !== expectedIds.at(-1) || + finalCamp?.camp?.campSkin?.campaignStep !== 'sixty-sixth-camp' || + finalReward?.battleId !== expectedIds.at(-1) || + finalReward?.visible !== true || + finalReward?.cards?.length !== 6 || + finalReward?.actions?.length !== 4 || + finalCamp?.rewardCloseVerified !== true || + finalCamp?.campAfterClose?.victoryRewardAcknowledgement?.visible !== false + ) { + throw new Error( + `Campaign completion save is inconsistent: ${JSON.stringify({ + results: results.length, + history: Object.keys(history).length, + missingIds, + invalidIntermissions: invalidIntermissions.map((result) => result.id), + latestBattleId: snapshot.state?.latestBattleId, + step: snapshot.state?.step, + reportBattleId: snapshot.state?.firstBattleReport?.battleId, + slotMatchesBase: snapshot.slotRaw === snapshot.baseRaw, + finalCamp + })}` + ); + } + + console.log(`Verified complete cumulative campaign save across ${expectedIds.length} real battle victories.`); +} + async function playBattleWithoutDebugVictory(page, battle) { return page.evaluate( async ({ no, id, maxRounds, targets, protected: protectedUnitIds }) => { @@ -1692,19 +2058,27 @@ async function playBattleWithoutDebugVictory(page, battle) { battleLogTail: [] }; - const state = () => window.__HEROS_DEBUG__.battle(); + const state = () => scene.debugBattleSimulationState(); const distance = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y); const unitRef = (unitId) => scene.debugUnitById(unitId); const liveUnits = (faction) => - state() - .units.filter((unit) => unit.faction === faction && unit.hp > 0) - .map((unit) => unitRef(unit.id)) - .filter(Boolean); - const actedUnitIds = () => new Set(state().actedUnitIds ?? []); + (scene.debugBattleUnits() ?? []).filter((unit) => unit.faction === faction && unit.hp > 0); + const actedUnitIds = () => new Set(scene.actedUnitIds ?? []); const syncUnit = (unit) => { scene.positionUnitView?.(unit); scene.updateMiniMap?.(); }; + const moveUnitForBattleSimulation = (unit, tile) => { + // Movement legality is already derived from reachableTiles(). The full + // interactive move path also renders every enemy-intent forecast, which + // is covered by its dedicated verifier and is prohibitively expensive + // when repeated across all 66 automated campaign battles. + unit.x = tile.x; + unit.y = tile.y; + scene.phase = 'command'; + scene.pendingMove = undefined; + syncUnit(unit); + }; const resolveOutcome = () => scene.resolveBattleOutcomeIfNeeded?.(); const activeSecureObjectives = () => (state().objectives ?? []).filter((objective) => { @@ -2890,8 +3264,7 @@ async function playBattleWithoutDebugVictory(page, battle) { scene.selectedUnit = unit; scene.phase = 'moving'; const tile = chooseMove(unit); - scene.moveSelectedUnit(tile.x, tile.y); - syncUnit(unit); + moveUnitForBattleSimulation(unit, tile); const attack = attackChoice(unit); if (attack && canTakeAttack(unit, attack)) { @@ -3019,6 +3392,24 @@ async function ensureLocalServer(url) { throw new Error(`Could not start dev server at ${url}`); } +async function readCampaignRouteChain() { + const source = await readFile(new URL('../src/game/state/campaignRouting.ts', import.meta.url), 'utf8'); + const mapBody = source.match(/const battleIdByCampaignStep[\s\S]*?=\s*\{([\s\S]*?)\n\};/)?.[1] ?? ''; + const routes = [...mapBody.matchAll(/^\s*'([^']+-battle)':\s*'([^']+)',?\s*$/gm)].map((match) => ({ + battleStep: match[1], + battleId: match[2], + campStep: match[1].replace(/-battle$/, '-camp') + })); + if ( + routes.length !== 66 || + new Set(routes.map((route) => route.battleStep)).size !== routes.length || + new Set(routes.map((route) => route.battleId)).size !== routes.length + ) { + throw new Error(`Could not derive the 66-battle campaign route chain: ${JSON.stringify(routes)}`); + } + return routes; +} + function localViteServerArgs(url) { let parsed; try { @@ -3192,6 +3583,7 @@ function buildQaReport(results, failed) { headless, maxRounds, cumulativeMode, + completionMode, requestedBattles: [...requestedBattles], battleCount: results.length, summary, @@ -3214,6 +3606,7 @@ function buildQaReport(results, failed) { enemyStatus: result.enemyStatus, battleLogTail: result.battleLogTail, campSuppliesUsed: result.campSuppliesUsed, + intermissionCamp: result.intermissionCamp, campaign: result.campaign })) }; diff --git a/scripts/verify-battle-action-asset-budgets.mjs b/scripts/verify-battle-action-asset-budgets.mjs new file mode 100644 index 0000000..f81a70d --- /dev/null +++ b/scripts/verify-battle-action-asset-budgets.mjs @@ -0,0 +1,376 @@ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const root = process.cwd(); +const unitDir = join(root, 'src', 'assets', 'images', 'units'); +const distAssetsDir = join(root, 'dist', 'assets'); +const policy = JSON.parse(readFileSync(join(root, 'src', 'game', 'data', 'unitActionAssetPolicy.json'), 'utf8')); +const MiB = 1024 * 1024; +const verifyDist = process.argv.includes('--dist'); +if (policy.optimizeAll !== true) { + throw new Error('Unit asset policy v3 must optimize every base and action sheet.'); +} + +const optimizedActionKeyList = readdirSync(unitDir) + .filter((file) => /^unit-.*-actions\.png$/i.test(file)) + .map((file) => file.replace(/-actions\.png$/i, '')) + .sort(); +const optimizedBaseKeyList = readdirSync(unitDir) + .filter((file) => /^unit-.*\.png$/i.test(file) && !/-actions\.png$/i.test(file)) + .map((file) => file.replace(/\.png$/i, '')) + .sort(); +const optimizedActionKeys = new Set(optimizedActionKeyList); +const optimizedBaseKeys = new Set(optimizedBaseKeyList); + +if (optimizedActionKeyList.length !== policy.expectedActionAssetCount) { + throw new Error(`Expected ${policy.expectedActionAssetCount} optimized action assets, found ${optimizedActionKeyList.length}.`); +} +if (optimizedBaseKeyList.length !== policy.expectedBaseAssetCount) { + throw new Error(`Expected ${policy.expectedBaseAssetCount} optimized base assets, found ${optimizedBaseKeyList.length}.`); +} +if (optimizedActionKeyList.some((key, index) => key !== optimizedBaseKeyList[index])) { + throw new Error('Base and action sprite key sets do not match.'); +} + +const representativeBattles = [ + { + id: 'first-battle-zhuo-commandery', + phase: 'early', + keys: [ + 'unit-liu-bei', + 'unit-guan-yu', + 'unit-zhang-fei', + 'unit-rebel', + 'unit-rebel-archer', + 'unit-rebel-cavalry', + 'unit-rebel-leader' + ], + budget: { actionEncodedMiB: 18, actionDecodedMiB: 150, combinedEncodedMiB: 25, combinedDecodedMiB: 210 } + }, + { + id: 'twenty-second-battle-red-cliffs-fire', + phase: 'middle', + keys: [ + 'unit-liu-bei', + 'unit-guan-yu', + 'unit-zhang-fei', + 'unit-shu-strategist-white', + 'unit-zhao-yun', + 'unit-zhuge-liang', + 'unit-wei-infantry-shield', + 'unit-wei-infantry-veteran', + 'unit-wei-infantry', + 'unit-wei-archer', + 'unit-wei-archer-crossbow', + 'unit-wei-archer-veteran', + 'unit-wei-cavalry', + 'unit-wei-cavalry-elite', + 'unit-wei-cavalry-iron', + 'unit-wei-officer-iron' + ], + budget: { actionEncodedMiB: 30, actionDecodedMiB: 330, combinedEncodedMiB: 40, combinedDecodedMiB: 475 } + }, + { + id: 'forty-sixth-battle-yiling-fire', + phase: 'late', + keys: [ + 'unit-liu-bei', + 'unit-zhao-yun', + 'unit-zhuge-liang', + 'unit-ma-liang', + 'unit-huang-quan', + 'unit-ma-chao', + 'unit-wang-ping', + 'unit-wu-infantry', + 'unit-wu-infantry-marine', + 'unit-wu-infantry-river', + 'unit-wu-archer-river', + 'unit-wu-archer', + 'unit-wu-archer-naval', + 'unit-wu-cavalry-elite', + 'unit-wu-cavalry-river', + 'unit-wu-cavalry', + 'unit-wu-strategist', + 'unit-wu-strategist-fire', + 'unit-wu-strategist-river', + 'unit-wu-officer', + 'unit-wu-officer-harbor', + 'unit-wu-officer-river' + ], + budget: { actionEncodedMiB: 35, actionDecodedMiB: 450, combinedEncodedMiB: 50, combinedDecodedMiB: 650 } + }, + { + id: 'sixty-sixth-battle-wuzhang-final', + phase: 'final', + keys: [ + 'unit-zhao-yun', + 'unit-zhuge-liang', + 'unit-li-yan', + 'unit-huang-quan', + 'unit-ma-chao', + 'unit-ma-dai', + 'unit-wang-ping', + 'unit-jiang-wei', + 'unit-wei-infantry', + 'unit-wei-infantry-shield', + 'unit-wei-infantry-veteran', + 'unit-wei-archer-veteran', + 'unit-wei-archer', + 'unit-wei-archer-crossbow', + 'unit-wei-cavalry-elite', + 'unit-wei-cavalry-iron', + 'unit-wei-cavalry', + 'unit-wei-strategist-blue', + 'unit-wei-strategist', + 'unit-wei-strategist-black', + 'unit-wei-officer', + 'unit-sima-yi' + ], + budget: { actionEncodedMiB: 35, actionDecodedMiB: 450, combinedEncodedMiB: 50, combinedDecodedMiB: 650 } + } +]; + +function imageDimensions(path) { + const buffer = readFileSync(path); + if (buffer.subarray(1, 4).toString('ascii') === 'PNG') { + return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; + } + if (buffer.subarray(0, 4).toString('ascii') !== 'RIFF' || buffer.subarray(8, 12).toString('ascii') !== 'WEBP') { + throw new Error(`Unsupported image format: ${path}`); + } + let offset = 12; + while (offset + 8 <= buffer.length) { + const chunk = buffer.subarray(offset, offset + 4).toString('ascii'); + const size = buffer.readUInt32LE(offset + 4); + const payload = offset + 8; + if (chunk === 'VP8X') { + return { + width: 1 + buffer.readUIntLE(payload + 4, 3), + height: 1 + buffer.readUIntLE(payload + 7, 3) + }; + } + if (chunk === 'VP8L') { + const bits = buffer.readUInt32LE(payload + 1); + return { + width: 1 + (bits & 0x3fff), + height: 1 + ((bits >>> 14) & 0x3fff) + }; + } + if (chunk === 'VP8 ') { + return { + width: buffer.readUInt16LE(payload + 6) & 0x3fff, + height: buffer.readUInt16LE(payload + 8) & 0x3fff + }; + } + offset = payload + size + (size % 2); + } + throw new Error(`Could not read WebP dimensions: ${path}`); +} + +function sheetAssetForKey(key, group, preferOptimized = true) { + const suffix = group === 'action' ? '-actions' : ''; + const optimizedSet = group === 'action' ? optimizedActionKeys : optimizedBaseKeys; + const useOptimized = preferOptimized && optimizedSet.has(key); + const extension = useOptimized ? policy.format : 'png'; + const file = `${key}${suffix}.${extension}`; + const path = join(unitDir, file); + if (!existsSync(path)) { + throw new Error(`Missing ${group} asset: ${path}`); + } + const dimensions = imageDimensions(path); + return { + key, + group, + extension, + file, + encodedBytes: statSync(path).size, + decodedBytes: dimensions.width * dimensions.height * 4, + ...dimensions + }; +} + +function byteTotals(assets) { + return { + encodedBytes: assets.reduce((sum, asset) => sum + asset.encodedBytes, 0), + decodedBytes: assets.reduce((sum, asset) => sum + asset.decodedBytes, 0) + }; +} + +function summarizeBattle(battle) { + const currentActionAssets = battle.keys.map((key) => sheetAssetForKey(key, 'action')); + const originalActionAssets = battle.keys.map((key) => sheetAssetForKey(key, 'action', false)); + const currentBaseAssets = battle.keys.map((key) => sheetAssetForKey(key, 'base')); + const originalBaseAssets = battle.keys.map((key) => sheetAssetForKey(key, 'base', false)); + const currentAction = byteTotals(currentActionAssets); + const originalAction = byteTotals(originalActionAssets); + const currentBase = byteTotals(currentBaseAssets); + const originalBase = byteTotals(originalBaseAssets); + const combinedEncodedBytes = currentAction.encodedBytes + currentBase.encodedBytes; + const combinedDecodedBytes = currentAction.decodedBytes + currentBase.decodedBytes; + const originalCombinedEncodedBytes = originalAction.encodedBytes + originalBase.encodedBytes; + const originalCombinedDecodedBytes = originalAction.decodedBytes + originalBase.decodedBytes; + const actionEncodedMiB = currentAction.encodedBytes / MiB; + const actionDecodedMiB = currentAction.decodedBytes / MiB; + const combinedEncodedMiB = combinedEncodedBytes / MiB; + const combinedDecodedMiB = combinedDecodedBytes / MiB; + const currentAssets = [...currentBaseAssets, ...currentActionAssets]; + const maxTextureEdge = Math.max(...currentAssets.flatMap((asset) => [asset.width, asset.height])); + const over8kTextureCount = currentAssets.filter((asset) => Math.max(asset.width, asset.height) > 8192).length; + + if ( + actionEncodedMiB > battle.budget.actionEncodedMiB || + actionDecodedMiB > battle.budget.actionDecodedMiB || + combinedEncodedMiB > battle.budget.combinedEncodedMiB || + combinedDecodedMiB > battle.budget.combinedDecodedMiB + ) { + throw new Error( + `${battle.id} unit sheet budget failed: action=${actionEncodedMiB.toFixed(1)}MiB encoded/` + + `${actionDecodedMiB.toFixed(1)}MiB decoded, combined=${combinedEncodedMiB.toFixed(1)}MiB encoded/` + + `${combinedDecodedMiB.toFixed(1)}MiB decoded` + ); + } + + return { + id: battle.id, + phase: battle.phase, + unitKeyCount: battle.keys.length, + textureCount: currentAssets.length, + optimizedTextureCount: currentAssets.filter((asset) => asset.extension === policy.format).length, + action: { + encodedMiB: round(actionEncodedMiB), + encodedReductionPercent: round((1 - currentAction.encodedBytes / originalAction.encodedBytes) * 100), + decodedMiB: round(actionDecodedMiB), + decodedReductionPercent: round((1 - currentAction.decodedBytes / originalAction.decodedBytes) * 100) + }, + base: { + encodedMiB: round(currentBase.encodedBytes / MiB), + encodedReductionPercent: round((1 - currentBase.encodedBytes / originalBase.encodedBytes) * 100), + decodedMiB: round(currentBase.decodedBytes / MiB), + decodedReductionPercent: round((1 - currentBase.decodedBytes / originalBase.decodedBytes) * 100) + }, + combined: { + encodedMiB: round(combinedEncodedMiB), + encodedReductionPercent: round((1 - combinedEncodedBytes / originalCombinedEncodedBytes) * 100), + decodedMiB: round(combinedDecodedMiB), + decodedReductionPercent: round((1 - combinedDecodedBytes / originalCombinedDecodedBytes) * 100) + }, + maxTextureEdge, + over8kTextureCount, + budget: battle.budget + }; +} + +function deployedSheetKey(file, group, extension) { + if (!file.toLowerCase().endsWith(`.${extension}`)) { + return undefined; + } + const stem = file.slice(0, -(extension.length + 1)); + if (group === 'base' && /-actions-/i.test(stem)) { + return undefined; + } + const keys = group === 'action' ? optimizedActionKeyList : optimizedBaseKeyList; + const suffix = group === 'action' ? '-actions-' : '-'; + return keys + .filter((key) => stem.startsWith(`${key}${suffix}`) && stem.length > key.length + suffix.length) + .sort((left, right) => right.length - left.length)[0]; +} + +function deployedAssetForKey(files, key, group) { + const webps = files.filter((file) => deployedSheetKey(file, group, 'webp') === key); + const pngs = files.filter((file) => deployedSheetKey(file, group, 'png') === key); + if (webps.length !== 1 || pngs.length !== 0) { + throw new Error( + `${key} ${group}: expected one deployed WebP and no deployed PNG, got webp=${webps.length}, png=${pngs.length}` + ); + } + return { key, group, file: webps[0], bytes: statSync(join(distAssetsDir, webps[0])).size }; +} + +function deploymentSummary() { + if (!existsSync(distAssetsDir)) { + throw new Error(`Missing deployment assets directory: ${distAssetsDir}`); + } + const files = readdirSync(distAssetsDir); + const base = optimizedBaseKeyList.map((key) => deployedAssetForKey(files, key, 'base')); + const action = optimizedActionKeyList.map((key) => deployedAssetForKey(files, key, 'action')); + const deployedUnitPngs = files.filter((file) => /^unit-.*\.png$/i.test(file)); + if (deployedUnitPngs.length !== 0) { + throw new Error(`Expected no deployed unit sheet PNG files, found: ${deployedUnitPngs.join(', ')}`); + } + return { + checked: true, + baseWebpFiles: base.length, + actionWebpFiles: action.length, + totalWebpFiles: base.length + action.length, + baseMiB: round(base.reduce((sum, asset) => sum + asset.bytes, 0) / MiB), + actionMiB: round(action.reduce((sum, asset) => sum + asset.bytes, 0) / MiB), + combinedMiB: round([...base, ...action].reduce((sum, asset) => sum + asset.bytes, 0) / MiB), + originalPngEmitted: false + }; +} + +function optimizedAssetDetails(group, keys, columns, rows) { + const expectedWidth = columns * policy.optimizedFrameSize; + const expectedHeight = rows * policy.optimizedFrameSize; + return keys.map((key) => { + const current = sheetAssetForKey(key, group); + const original = sheetAssetForKey(key, group, false); + if (current.width !== expectedWidth || current.height !== expectedHeight) { + throw new Error(`${current.file}: expected ${expectedWidth}x${expectedHeight}, got ${current.width}x${current.height}`); + } + return { + key, + sourceEncodedBytes: original.encodedBytes, + optimizedEncodedBytes: current.encodedBytes, + sourceDecodedBytes: original.decodedBytes, + optimizedDecodedBytes: current.decodedBytes + }; + }); +} + +function summarizeOptimizedAssets(details) { + return { + count: details.length, + sourceEncodedMiB: round(details.reduce((sum, asset) => sum + asset.sourceEncodedBytes, 0) / MiB), + optimizedEncodedMiB: round(details.reduce((sum, asset) => sum + asset.optimizedEncodedBytes, 0) / MiB), + encodedReductionPercent: round( + (1 - details.reduce((sum, asset) => sum + asset.optimizedEncodedBytes, 0) / + details.reduce((sum, asset) => sum + asset.sourceEncodedBytes, 0)) * 100 + ), + sourceDecodedMiB: round(details.reduce((sum, asset) => sum + asset.sourceDecodedBytes, 0) / MiB), + optimizedDecodedMiB: round(details.reduce((sum, asset) => sum + asset.optimizedDecodedBytes, 0) / MiB), + decodedReductionPercent: round( + (1 - details.reduce((sum, asset) => sum + asset.optimizedDecodedBytes, 0) / + details.reduce((sum, asset) => sum + asset.sourceDecodedBytes, 0)) * 100 + ) + }; +} + +const optimizedBaseAssets = optimizedAssetDetails('base', optimizedBaseKeyList, policy.baseColumns, policy.baseRows); +const optimizedActionAssets = optimizedAssetDetails('action', optimizedActionKeyList, policy.actionColumns, policy.actionRows); + +const report = { + viewportBaseline: { width: 1920, height: 1080, deviceScaleFactor: 1, zoomPercent: 100 }, + policy: { + version: policy.version, + baseFrameCount: policy.baseColumns * policy.baseRows, + actionFrameCount: policy.actionColumns * policy.actionRows, + sourceFrameSize: policy.sourceFrameSize, + optimizedFrameSize: policy.optimizedFrameSize, + format: policy.lossless ? 'lossless-webp' : policy.format, + optimizedBaseAssetCount: optimizedBaseKeyList.length, + optimizedActionAssetCount: optimizedActionKeyList.length + }, + optimizedAssets: { + base: summarizeOptimizedAssets(optimizedBaseAssets), + action: summarizeOptimizedAssets(optimizedActionAssets) + }, + representativeBattles: representativeBattles.map(summarizeBattle), + deployment: verifyDist ? deploymentSummary() : { checked: false, hint: 'Run with --dist after a production build.' } +}; + +console.log(JSON.stringify(report, null, 2)); + +function round(value) { + return Math.round(value * 10) / 10; +} diff --git a/scripts/verify-battle-forecast.mjs b/scripts/verify-battle-forecast.mjs new file mode 100644 index 0000000..8b9da9d --- /dev/null +++ b/scripts/verify-battle-forecast.mjs @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createServer } from 'vite'; + +const server = await createServer({ + logLevel: 'error', + server: { host: '127.0.0.1', port: 0, hmr: false }, + appType: 'custom' +}); + +try { + const { + buildCombatExchangeProjection, + compareMoveIntentRisk, + summarizeIntentRisk + } = await server.ssrLoadModule('/src/game/data/battleForecast.ts'); + + const before = [ + { enemyId: 'e1', kind: 'attack', targetId: 'ally', damage: 20, hitRate: 80 }, + { enemyId: 'e2', kind: 'usable', targetId: 'ally', damage: 10, hitRate: 100 }, + { enemyId: 'e3', kind: 'approach', targetId: 'ally', damage: 0, hitRate: 0 } + ]; + const afterSaferMove = [ + { enemyId: 'e1', kind: 'approach', targetId: 'ally', damage: 0, hitRate: 0 }, + { enemyId: 'e2', kind: 'attack', targetId: 'other', damage: 10, hitRate: 100 }, + { enemyId: 'e3', kind: 'approach', targetId: 'ally', damage: 0, hitRate: 0 } + ]; + + assert.deepEqual( + summarizeIntentRisk(before, 'ally', 25), + { + immediateCount: 2, + approachCount: 1, + expectedDamage: 26, + rawDamage: 30, + criticalAdjustedMaxDamage: 30, + lethal: true, + immediateEnemyIds: ['e1', 'e2'] + }, + 'Intent risk must distinguish immediate damage from approaching enemies.' + ); + + const safer = compareMoveIntentRisk(before, afterSaferMove, 'ally', 25); + assert.equal(safer.tone, 'safer', 'Breaking an immediate attack line must be marked safer.'); + assert.equal(safer.immediateDelta, -2, 'Move preview must report removed immediate attackers.'); + assert.equal(safer.expectedDamageDelta, -26, 'Move preview must report the selected unit damage delta.'); + assert.equal(safer.retargetedAwayCount, 1, 'Move preview must report enemies redirected away from the mover.'); + assert.equal(safer.neutralizedImmediateCount, 1, 'Move preview must report an immediate attack converted to approach.'); + + const afterRiskierMove = [ + ...before, + { enemyId: 'e4', kind: 'attack', targetId: 'ally', damage: 12, hitRate: 75 } + ]; + const riskier = compareMoveIntentRisk(before, afterRiskierMove, 'ally', 25); + assert.equal(riskier.tone, 'riskier', 'A new incoming attack must be marked riskier.'); + assert.equal(riskier.immediateDelta, 1); + assert.equal(riskier.expectedDamageDelta, 9); + assert.equal(riskier.retargetedToCount, 1); + + const criticalIntent = summarizeIntentRisk( + [{ enemyId: 'critical-enemy', kind: 'attack', targetId: 'ally', damage: 11, hitRate: 100, criticalRate: 20 }], + 'ally', + 15 + ); + assert.equal(criticalIntent.expectedDamage, 12, 'Intent expected damage must include critical probability.'); + assert.equal(criticalIntent.criticalAdjustedMaxDamage, 17, 'Intent maximum damage must match the real 1.5x rounded critical rule.'); + assert.equal(criticalIntent.lethal, true, 'A possible critical defeat must be exposed as lethal risk.'); + + const exchange = buildCombatExchangeProjection({ + attackerHp: 34, + defenderHp: 30, + attackDamage: 18, + attackHitRate: 90, + attackCriticalRate: 10, + counter: { damage: 12, hitRate: 80, criticalRate: 5 } + }); + assert.equal(exchange.defenderHpAfterHit, 12); + assert.equal(exchange.attackerHpAfterCounterHit, 22); + assert.equal(exchange.counterCondition, 'if-defender-survives'); + assert.equal(exchange.attackLethalOnHit, false); + assert.equal(exchange.counterLethalOnHit, false); + assert.ok(exchange.attackExpectedDamage > exchange.counterExpectedDamage); + + const lethalExchange = buildCombatExchangeProjection({ + attackerHp: 10, + defenderHp: 16, + attackDamage: 18, + attackHitRate: 90, + attackCriticalRate: 10, + counter: { damage: 12, hitRate: 80, criticalRate: 5 } + }); + assert.equal(lethalExchange.defenderHpAfterHit, 0); + assert.equal(lethalExchange.counterCondition, 'on-primary-miss', 'A lethal hit must explain that only a miss permits counterattack.'); + assert.equal(lethalExchange.counterLethalOnHit, true, 'Counter forecast must expose attacker defeat risk.'); + + const criticalCounterExchange = buildCombatExchangeProjection({ + attackerHp: 15, + defenderHp: 30, + attackDamage: 8, + attackHitRate: 100, + attackCriticalRate: 0, + counter: { damage: 11, hitRate: 90, criticalRate: 20 } + }); + assert.equal(criticalCounterExchange.attackerHpAfterCounterHit, 4); + assert.equal(criticalCounterExchange.attackerHpAfterCounterCriticalHit, 0); + assert.equal(criticalCounterExchange.counterLethalOnHit, false); + assert.equal(criticalCounterExchange.counterLethalOnCritical, true, 'Counter forecast must expose critical-only defeat risk.'); + + const noCounter = buildCombatExchangeProjection({ + attackerHp: 20, + defenderHp: 20, + attackDamage: 8, + attackHitRate: 85, + attackCriticalRate: 5 + }); + assert.equal(noCounter.counterCondition, 'none'); + assert.equal(noCounter.attackerHpAfterCounterHit, 20); + assert.equal(noCounter.counterExpectedDamage, 0); + + const battleSceneSource = await readFile(new URL('../src/game/scenes/BattleScene.ts', import.meta.url), 'utf8'); + assert.match(battleSceneSource, /moveIntentRiskForTile\(unit, tile\.x, tile\.y\)/, 'Move hover must invoke intent comparison.'); + assert.match(battleSceneSource, /handleBattleKeyboardNavigation\(event\)/, 'Keyboard navigation must remain wired.'); + assert.match(battleSceneSource, /combatPreview\(preview\.defender, preview\.attacker, 'attack', undefined, true\)/, 'Exchange forecast must reuse counter combat rules.'); + assert.match(battleSceneSource, /combatExchangeCounterMetric\(exchange\)/, 'Target cards must show numeric counter data.'); + assert.match(battleSceneSource, /criticalRate: plan\.preview\?\.criticalRate \?\? 0/, 'Move risk snapshots must retain enemy critical rates.'); + assert.match(battleSceneSource, /counterLethalOnCritical/, 'Counter UI must surface critical-only defeat risk.'); + + console.log('Verified critical-aware move-intent deltas, exchange projections, keyboard wiring, and counter-rule reuse.'); +} finally { + await server.close(); +} diff --git a/scripts/verify-battle-save-normalization.mjs b/scripts/verify-battle-save-normalization.mjs index 2f37356..0c5b624 100644 --- a/scripts/verify-battle-save-normalization.mjs +++ b/scripts/verify-battle-save-normalization.mjs @@ -35,6 +35,16 @@ try { assert(normalizeBattleSaveSlot(Number.NaN, 3) === 1, 'Expected NaN slot to normalize to slot 1.'); assert(normalizeBattleSaveSlot(99, 3) === 3, 'Expected overflowing slot to clamp to slot count.'); assert(isValidBattleSaveState(validState, options), 'Expected valid battle save to pass validation.'); + const parsedProgressedStats = parseBattleSaveState(JSON.stringify(validState), options)?.units[0]?.stats; + assert( + JSON.stringify(parsedProgressedStats) === JSON.stringify(validState.units[0].stats), + 'Expected battle-earned unit attributes to round-trip through battle-save parsing.' + ); + const legacyUnitsWithoutStats = validState.units.map(({ stats: _stats, ...unit }) => unit); + assert( + isValidBattleSaveState({ ...validState, units: legacyUnitsWithoutStats }, options), + 'Expected legacy battle saves without unit attributes to remain valid.' + ); const validCompletedAttackHistoryState = { ...validState, actedUnitIds: ['liu-bei', 'guan-yu'], @@ -361,6 +371,9 @@ try { ['invalid unit attack', { units: patchUnit(0, { attack: -1 }) }], ['invalid unit move precision', { units: patchUnit(0, { move: 3.5 }) }], ['too high unit move', { units: patchUnit(0, { move: 100 }) }], + ['invalid unit stats shape', { units: patchUnit(0, { stats: { might: 80 } }) }], + ['invalid unit stats precision', { units: patchUnit(0, { stats: { ...validState.units[0].stats, agility: 72.5 } }) }], + ['too high unit stat', { units: patchUnit(0, { stats: { ...validState.units[0].stats, might: 1000 } }) }], ['duplicate live unit tile', { units: patchUnit(1, { x: validState.units[0].x, y: validState.units[0].y }) }], ['invalid unit x', { units: patchUnit(0, { x: 12 }) }], ['invalid unit direction', { units: patchUnit(0, { direction: 'down' }) }], @@ -567,6 +580,7 @@ function createValidBattleSaveState() { maxHp: 30, attack: 10, move: 4, + stats: { might: 78, intelligence: 82, leadership: 85, agility: 72, luck: 88 }, x: 1, y: 2, direction: 'south', @@ -580,6 +594,7 @@ function createValidBattleSaveState() { maxHp: 34, attack: 14, move: 4, + stats: { might: 97, intelligence: 76, leadership: 92, agility: 73, luck: 69 }, x: 2, y: 2, direction: 'south', @@ -593,6 +608,7 @@ function createValidBattleSaveState() { maxHp: 20, attack: 8, move: 3, + stats: { might: 52, intelligence: 34, leadership: 39, agility: 46, luck: 41 }, x: 8, y: 4, direction: 'west', diff --git a/scripts/verify-battle-save-resume-routing.mjs b/scripts/verify-battle-save-resume-routing.mjs new file mode 100644 index 0000000..4965f52 --- /dev/null +++ b/scripts/verify-battle-save-resume-routing.mjs @@ -0,0 +1,293 @@ +import { readFile } from 'node:fs/promises'; +import { createServer } from 'vite'; + +const browserStorage = createStorage(); +globalThis.window = { localStorage: browserStorage }; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true, hmr: false }, + appType: 'custom' +}); + +try { + const { + battleSaveStorageBaseKey, + battleSaveStorageSlotKey, + campaignBattleResumeStorageKeys, + clearAllCampaignBattleSaves, + clearCampaignBattleSavesForSlot, + clearCampaignBattleResume, + legacyFirstBattleSaveStorageKey, + readBattleSaveStorageCandidate, + readCampaignBattleResume + } = await server.ssrLoadModule('/src/game/state/battleSaveStorage.ts'); + const { resetCampaignState, startNewCampaign } = await server.ssrLoadModule('/src/game/state/campaignState.ts'); + + const firstBattleId = 'first-battle-zhuo-commandery'; + const campaign = { step: 'first-battle', activeSaveSlot: 2 }; + const storage = createStorage(); + const slotKey = battleSaveStorageSlotKey(firstBattleId, 2); + storage.setItem(slotKey, JSON.stringify(createBattleSave({ turnNumber: 4, activeFaction: 'enemy' }))); + const directResume = readCampaignBattleResume(campaign, 2, storage); + assert( + directResume?.slot === 2 && + directResume.battleId === firstBattleId && + directResume.turnNumber === 4 && + directResume.activeFaction === 'enemy' && + directResume.storageKey === slotKey, + `Expected a matching slotted battle save to become a direct-resume candidate: ${JSON.stringify(directResume)}` + ); + + storage.setItem(slotKey, '{broken-json'); + assert(readCampaignBattleResume(campaign, 2, storage) === undefined, 'Broken JSON must not become a resume candidate.'); + assert(storage.getItem(slotKey) === null, 'Broken slotted battle saves must be removed after validation.'); + + storage.setItem(slotKey, JSON.stringify(createBattleSave({ campaignStep: 'second-battle' }))); + assert(readCampaignBattleResume(campaign, 2, storage) === undefined, 'A stale campaign step must not resume another battle flow.'); + assert(storage.getItem(slotKey) === null, 'A stale campaign-step save must be cleaned safely.'); + + const slotOneCampaign = { step: 'first-battle', activeSaveSlot: 1 }; + const slotOneKey = battleSaveStorageSlotKey(firstBattleId, 1); + const baseKey = battleSaveStorageBaseKey(firstBattleId); + storage.setItem(slotOneKey, 'corrupted-slot-copy'); + storage.setItem(baseKey, JSON.stringify(createBattleSave({ turnNumber: 3 }))); + const fallbackResume = readCampaignBattleResume(slotOneCampaign, 1, storage); + assert( + fallbackResume?.storageKey === baseKey && fallbackResume.turnNumber === 3 && storage.getItem(slotOneKey) === null, + `A corrupt primary key must be removed before falling back to the compatible base key: ${JSON.stringify(fallbackResume)}` + ); + + storage.clear(); + storage.setItem(slotOneKey, JSON.stringify(createBattleSave({ + units: [{ ...createBattleSave().units[0], id: 'unknown-unit' }] + }))); + storage.setItem(baseKey, JSON.stringify(createBattleSave({ turnNumber: 5 }))); + const fullyValidatedFallback = readBattleSaveStorageCandidate( + firstBattleId, + 'first-battle', + 1, + storage, + 3, + { + mapWidth: 20, + mapHeight: 20, + validUnitIds: new Set(['liu-bei']), + validAllyUnitIds: new Set(['liu-bei']), + validEnemyUnitIds: new Set() + } + ); + assert( + fullyValidatedFallback?.storageKey === baseKey && + fullyValidatedFallback.state.turnNumber === 5 && + storage.getItem(slotOneKey) === null && + storage.getItem(baseKey) !== null, + `Full validation must delete only an invalid primary candidate before preserving and loading the valid base fallback: ${JSON.stringify(fullyValidatedFallback)}` + ); + + storage.clear(); + const alternateCompositionSave = createBattleSave({ + units: [{ ...createBattleSave().units[0], id: 'guan-yu' }] + }); + storage.setItem(slotKey, JSON.stringify(alternateCompositionSave)); + const looselyValidatedAlternateComposition = readBattleSaveStorageCandidate( + firstBattleId, + 'first-battle', + 2, + storage, + 3, + { mapWidth: 20, mapHeight: 20 } + ); + assert( + looselyValidatedAlternateComposition?.state.units[0]?.id === 'guan-yu' && storage.getItem(slotKey) !== null, + 'Inspecting another slot must preserve a structurally valid save until its campaign composition is loaded.' + ); + + storage.clear(); + storage.setItem(legacyFirstBattleSaveStorageKey, JSON.stringify(createBattleSave({ turnNumber: 2 }))); + const legacyResume = readCampaignBattleResume(slotOneCampaign, 1, storage); + assert( + legacyResume?.storageKey === legacyFirstBattleSaveStorageKey && legacyResume.turnNumber === 2, + `The first-battle legacy key must remain resumable for slot 1: ${JSON.stringify(legacyResume)}` + ); + + const keys = campaignBattleResumeStorageKeys(firstBattleId, 1); + assert( + JSON.stringify(keys) === JSON.stringify([slotOneKey, baseKey, legacyFirstBattleSaveStorageKey]), + `Resume keys must retain slotted, base, then legacy fallback order: ${JSON.stringify(keys)}` + ); + assert(clearCampaignBattleResume(slotOneCampaign, 1, storage) === true, 'Explicit resume cleanup must report removed data.'); + assert(storage.getItem(legacyFirstBattleSaveStorageKey) === null, 'Explicit cleanup must remove the legacy candidate.'); + + storage.setItem(slotOneKey, JSON.stringify(createBattleSave())); + const campProgress = { step: 'first-camp', activeSaveSlot: 1 }; + assert(readCampaignBattleResume(campProgress, 1, storage) === undefined, 'Camp progress must never consume an old battle save.'); + assert(storage.getItem(slotOneKey) !== null, 'Unrelated camp routing must leave battle data untouched.'); + + const secondBattleKey = battleSaveStorageSlotKey('second-battle-yellow-turban-pursuit', 3); + const unrelatedKey = 'heros-web:unrelated-setting'; + storage.setItem(baseKey, JSON.stringify(createBattleSave())); + storage.setItem(secondBattleKey, JSON.stringify(createBattleSave({ + battleId: 'second-battle-yellow-turban-pursuit', + campaignStep: 'second-battle' + }))); + storage.setItem(unrelatedKey, 'keep'); + const clearedCount = clearAllCampaignBattleSaves(storage); + assert( + clearedCount >= 3 && + storage.getItem(slotOneKey) === null && + storage.getItem(baseKey) === null && + storage.getItem(secondBattleKey) === null && + storage.getItem(unrelatedKey) === 'keep', + `Full cleanup must remove every campaign battle key without touching unrelated settings: ${clearedCount}` + ); + + browserStorage.setItem(slotOneKey, JSON.stringify(createBattleSave())); + resetCampaignState(); + assert(browserStorage.getItem(slotOneKey) === null, 'Campaign reset must clear prior-generation battle saves.'); + browserStorage.setItem(secondBattleKey, JSON.stringify(createBattleSave({ + battleId: 'second-battle-yellow-turban-pursuit', + campaignStep: 'second-battle' + }))); + browserStorage.setItem(slotOneKey, JSON.stringify(createBattleSave())); + startNewCampaign(); + assert( + browserStorage.getItem(slotOneKey) === null && browserStorage.getItem(secondBattleKey) !== null, + 'Starting a new campaign must clear slot 1 battle data without deleting resumable saves from preserved campaign slots.' + ); + assert( + clearCampaignBattleSavesForSlot(3, browserStorage) === 1 && browserStorage.getItem(secondBattleKey) === null, + 'Slot-scoped cleanup must remove only battle saves belonging to the requested campaign slot.' + ); + + storage.clear(); + const slotTwoFirstBattleKey = battleSaveStorageSlotKey(firstBattleId, 2); + const slotTwoSecondBattleKey = battleSaveStorageSlotKey('second-battle-yellow-turban-pursuit', 2); + storage.setItem(slotTwoFirstBattleKey, JSON.stringify(createBattleSave({ turnNumber: 8 }))); + storage.setItem(slotTwoSecondBattleKey, JSON.stringify(createBattleSave({ + battleId: 'second-battle-yellow-turban-pursuit', + campaignStep: 'second-battle', + turnNumber: 9 + }))); + clearCampaignBattleSavesForSlot(2, storage); + storage.setItem(slotTwoFirstBattleKey, JSON.stringify(createBattleSave({ turnNumber: 2 }))); + assert( + readCampaignBattleResume({ step: 'first-battle', activeSaveSlot: 2 }, 2, storage)?.turnNumber === 2 && + readCampaignBattleResume({ step: 'second-battle', activeSaveSlot: 2 }, 2, storage) === undefined && + storage.getItem(slotTwoSecondBattleKey) === null, + 'Replacing a battle slot must preserve only the newly written current battle and remove stale later-battle resumes.' + ); + + storage.clear(); + storage.setItem(slotOneKey, JSON.stringify(createBattleSave({ turnNumber: 7 }))); + storage.setItem(baseKey, JSON.stringify(createBattleSave({ turnNumber: 7 }))); + storage.setItem(legacyFirstBattleSaveStorageKey, JSON.stringify(createBattleSave({ turnNumber: 7 }))); + clearCampaignBattleSavesForSlot(1, storage); + storage.setItem(slotOneKey, JSON.stringify(createBattleSave({ turnNumber: 2 }))); + storage.setItem(baseKey, JSON.stringify(createBattleSave({ turnNumber: 2 }))); + assert( + storage.getItem(legacyFirstBattleSaveStorageKey) === null && + readCampaignBattleResume(slotOneCampaign, 1, storage)?.turnNumber === 2, + 'Replacing slot 1 must remove its legacy generation before writing matching slot and base compatibility copies.' + ); + + const battleSceneSource = await readFile(new URL('../src/game/scenes/BattleScene.ts', import.meta.url), 'utf8'); + const campSceneSource = await readFile(new URL('../src/game/scenes/CampScene.ts', import.meta.url), 'utf8'); + assert( + /resumeBattleSaveSlot\?: number/.test(battleSceneSource) && + /this\.time\.delayedCall\(0, \(\) => this\.loadBattleState\(resumeSlot\)\)/.test(battleSceneSource), + 'BattleScene must consume the title resume slot after the map and unit views are ready.' + ); + assert( + /clearBattleSaveStorageForBattle\(battleScenario\.id, getCampaignState\(\)\.activeSaveSlot\)/.test(battleSceneSource), + 'A victory must clear its resumable battle save from the active campaign slot.' + ); + assert( + /this\.activeFaction === 'enemy'[\s\S]*?void this\.runEnemyTurn\(\)/.test(battleSceneSource), + 'Resuming an enemy-turn save must restart enemy actions instead of leaving the battle stalled.' + ); + assert( + /restartBattleFromSave\(slot: number\)[\s\S]*?loadCampaignState\(normalizedSlot\)[\s\S]*?this\.scene\.restart\([\s\S]*?resumeBattleSaveSlot: normalizedSlot/.test(battleSceneSource) && + /readBattleSaveState\(slot: number, validateCurrentComposition = false\)/.test(battleSceneSource) && + /readBattleSaveState\(slot, true\)/.test(battleSceneSource), + 'Cross-slot loads must restart after loading the target campaign, then validate against the rebuilt battle composition.' + ); + assert( + /saveCampToSlot\(slot: number\)[\s\S]*?clearCampaignBattleSavesForSlot\(slot\)[\s\S]*?saveCampaignState\(getCampaignState\(\), slot\)/.test(campSceneSource), + 'Camp slot overwrites must clear all stale battle resumes before replacing the destination campaign save.' + ); + assert( + /saveBattleState\(slot = 1\)[\s\S]*?const state = this\.createBattleSaveState\(\)[\s\S]*?clearCampaignBattleSavesForSlot\(normalizedSlot\)[\s\S]*?localStorage\.setItem\(this\.battleSaveStorageKeyForSlot\(normalizedSlot\)/.test(battleSceneSource), + 'Battle slot overwrites must clear all destination battle resumes before writing the current battle snapshot.' + ); + assert( + /stats: \{ \.\.\.unit\.stats \}/.test(battleSceneSource) && + /unit\.stats = \{ \.\.\.savedUnit\.stats \}/.test(battleSceneSource) && + /progressUnitStatsForLevelUps\(unit\.stats, unit\.classKey, savedLevelUps\)/.test(battleSceneSource), + 'Battle saves must persist new attributes and reconstruct battle-earned attributes for legacy saves.' + ); + + console.log('Verified direct battle-save resume routing, BattleScene restore hooks, stale cleanup, fallback order, generation cleanup, and non-battle isolation.'); +} finally { + await server.close(); +} + +function createStorage() { + const values = new Map(); + return { + getItem(key) { + return values.has(key) ? values.get(key) : null; + }, + setItem(key, value) { + values.set(key, String(value)); + }, + removeItem(key) { + values.delete(key); + }, + clear() { + values.clear(); + } + }; +} + +function createBattleSave(patch = {}) { + return { + version: 1, + battleId: 'first-battle-zhuo-commandery', + campaignStep: 'first-battle', + savedAt: '2026-07-19T03:00:00.000Z', + turnNumber: 2, + activeFaction: 'ally', + rosterTab: 'ally', + actedUnitIds: [], + attackIntents: [], + battleLog: ['전투 저장'], + units: [ + { + id: 'liu-bei', + level: 1, + exp: 0, + hp: 30, + maxHp: 30, + attack: 10, + move: 4, + x: 1, + y: 1, + direction: 'south', + equipment: { + weapon: { itemId: 'training-sword', level: 1, exp: 0 }, + armor: { itemId: 'cloth-armor', level: 1, exp: 0 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + } + } + ], + bonds: [], + ...patch + }; +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} diff --git a/scripts/verify-campaign-completion.mjs b/scripts/verify-campaign-completion.mjs new file mode 100644 index 0000000..4d6655a --- /dev/null +++ b/scripts/verify-campaign-completion.mjs @@ -0,0 +1,186 @@ +import { createServer } from 'vite'; + +const storage = new Map(); +globalThis.window = { + localStorage: { + getItem(key) { + return storage.has(key) ? storage.get(key) : null; + }, + setItem(key, value) { + storage.set(key, String(value)); + }, + removeItem(key) { + storage.delete(key); + }, + clear() { + storage.clear(); + } + } +}; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true }, + appType: 'custom' +}); + +try { + const { + campaignStorageKey, + getCampaignState, + loadCampaignState, + resetCampaignState, + setFirstBattleReport + } = await server.ssrLoadModule('/src/game/state/campaignState.ts'); + const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); + const { campaignRecruitUnits } = await server.ssrLoadModule('/src/game/data/scenario.ts'); + const { campaignBattleRouteEntries } = await server.ssrLoadModule('/src/game/state/campaignRouting.ts'); + + const scenarios = Object.values(battleScenarios); + const routeEntries = campaignBattleRouteEntries(); + assert(scenarios.length === 66, `Expected 66 campaign battles, received ${scenarios.length}.`); + assert(routeEntries.length === 66, `Expected 66 campaign battle routes, received ${routeEntries.length}.`); + assert( + JSON.stringify(routeEntries.map((entry) => entry.battleId)) === JSON.stringify(scenarios.map((scenario) => scenario.id)), + 'Battle scenario order differs from the independent campaign routing table.' + ); + assert(scenarios[0]?.id === 'first-battle-zhuo-commandery', `Unexpected first battle: ${scenarios[0]?.id}`); + assert(scenarios.at(-1)?.id === 'sixty-sixth-battle-wuzhang-final', `Unexpected final battle: ${scenarios.at(-1)?.id}`); + + const recruitById = new Map(campaignRecruitUnits.map((unit) => [unit.id, unit])); + const expectedRecruitIds = new Set(); + const checkpointBattles = new Set([1, 11, 22, 33, 44, 55, 66]); + let expectedGold = 0; + + resetCampaignState(); + + for (const [index, scenario] of scenarios.entries()) { + const campaignReward = createCampaignRewardSnapshot(scenario, recruitById, battleScenarios); + campaignReward.recruits.forEach((recruit) => expectedRecruitIds.add(recruit.unitId)); + const objectives = scenario.objectives.map((objective) => ({ + id: objective.id, + label: objective.label, + achieved: true, + status: 'done', + category: objective.kind === 'defeat-leader' || objective.kind === 'pacify-leader' ? 'primary' : 'bonus', + detail: objective.label, + rewardGold: objective.rewardGold, + ...(objective.targetTile ? { targetTile: { ...objective.targetTile } } : {}) + })); + const rewardGold = scenario.baseVictoryGold + objectives.reduce((sum, objective) => sum + objective.rewardGold, 0); + expectedGold += rewardGold; + + setFirstBattleReport({ + battleId: scenario.id, + battleTitle: scenario.title, + outcome: 'victory', + turnNumber: Math.max(1, Math.min(scenario.quickVictoryTurnLimit, 8 + (index % 5))), + rewardGold, + defeatedEnemies: scenario.units.filter((unit) => unit.faction === 'enemy').length, + totalEnemies: scenario.units.filter((unit) => unit.faction === 'enemy').length, + objectives, + units: structuredClone(scenario.units), + bonds: structuredClone(scenario.bonds), + itemRewards: [...scenario.itemRewards], + campaignRewards: campaignReward, + completedCampDialogues: [], + completedCampVisits: [], + createdAt: new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString() + }); + + let state = getCampaignState(); + const battleNumber = index + 1; + assert(state.latestBattleId === scenario.id, `Battle ${battleNumber} did not become the latest settlement.`); + assert(state.battleHistory[scenario.id]?.outcome === 'victory', `Battle ${battleNumber} victory was not recorded.`); + assert(Object.keys(state.battleHistory).length === battleNumber, `Battle history stopped at ${Object.keys(state.battleHistory).length}/${battleNumber}.`); + assert(state.step === expectedCampStep(scenario.id), `Battle ${battleNumber} routed to ${state.step}, expected ${expectedCampStep(scenario.id)}.`); + assert(state.roster.every((unit, unitIndex, roster) => roster.findIndex((candidate) => candidate.id === unit.id) === unitIndex), `Battle ${battleNumber} produced duplicate roster entries.`); + assert(Object.values(state.inventory).every((amount) => Number.isInteger(amount) && amount > 0), `Battle ${battleNumber} produced invalid inventory amounts.`); + + state = checkpointBattles.has(battleNumber) + ? roundTripLegacyCompatibleSave(campaignStorageKey, state, loadCampaignState) + : loadCampaignState(1); + const expectedHistoryIds = scenarios.slice(0, battleNumber).map((candidate) => candidate.id); + assert( + JSON.stringify(Object.keys(state.battleHistory)) === JSON.stringify(expectedHistoryIds), + `Save reload changed campaign order at battle ${battleNumber}.` + ); + assert(state.latestBattleId === scenario.id, `Save reload lost the latest battle at ${battleNumber}.`); + assert(state.step === expectedCampStep(scenario.id), `Save reload changed the route at battle ${battleNumber}.`); + } + + const finalState = getCampaignState(); + const scenarioIds = new Set(scenarios.map((scenario) => scenario.id)); + assert(finalState.step === 'sixty-sixth-camp', `Expected the final victory to reach the final camp, received ${finalState.step}.`); + assert(finalState.latestBattleId === 'sixty-sixth-battle-wuzhang-final', `Unexpected final latest battle: ${finalState.latestBattleId}`); + assert(Object.keys(finalState.battleHistory).length === 66, `Expected 66 settlements, received ${Object.keys(finalState.battleHistory).length}.`); + assert(Object.keys(finalState.battleHistory).every((battleId) => scenarioIds.has(battleId)), 'Final history contains an unknown battle id.'); + assert(Object.values(finalState.battleHistory).every((settlement) => settlement.outcome === 'victory'), 'Final history contains a non-victory settlement.'); + const expectedStoredGold = Math.min(999999, expectedGold); + assert(finalState.gold === expectedStoredGold, `Expected capped gold ${expectedStoredGold} from ${expectedGold}, received ${finalState.gold}.`); + assert([...expectedRecruitIds].every((unitId) => finalState.roster.some((unit) => unit.id === unitId)), 'Final roster is missing a campaign recruit.'); + assert(finalState.firstBattleReport?.battleId === finalState.latestBattleId, 'Final report and latest settlement are out of sync.'); + + const finalSlotRaw = storage.get(`${campaignStorageKey}:slot-1`); + assert(finalSlotRaw === storage.get(campaignStorageKey), 'Base save and active slot diverged after campaign completion.'); + assert(JSON.parse(finalSlotRaw).battleHistory['sixty-sixth-battle-wuzhang-final'], 'Serialized final save is missing the last settlement.'); + + console.log( + `Verified all ${scenarios.length} campaign settlements in route order, every-battle save reloads, seven legacy migrations, ${expectedRecruitIds.size} recruit unlocks, reward accumulation, and the final-camp state.` + ); +} finally { + await server.close(); +} + +function createCampaignRewardSnapshot(scenario, recruitById, battleScenarios) { + const reward = scenario.campaignReward; + return { + supplies: [...(reward?.supplies ?? [])], + equipment: [...(reward?.equipment ?? [])], + reputation: [...(reward?.reputation ?? [])], + recruits: (reward?.recruits ?? []).map((unitId) => { + const unit = scenario.units.find((candidate) => candidate.id === unitId) ?? recruitById.get(unitId); + assert(unit, `${scenario.id} references unknown recruit ${unitId}.`); + return { unitId, name: unit.name }; + }), + unlocks: reward?.unlockBattleId + ? [{ + battleId: reward.unlockBattleId, + title: reward.unlockLabel ?? battleScenarios[reward.unlockBattleId]?.title ?? reward.unlockBattleId + }] + : [], + ...(reward?.note ? { note: reward.note } : {}) + }; +} + +function roundTripLegacyCompatibleSave(campaignStorageKey, state, loadCampaignState) { + const legacySnapshot = structuredClone(state); + delete legacySnapshot.sortieFormationAssignments; + delete legacySnapshot.sortieItemAssignments; + delete legacySnapshot.sortieFormationPresets; + delete legacySnapshot.sortieOrderSelection; + delete legacySnapshot.sortieResonanceSelection; + delete legacySnapshot.sortieRecommendationSelection; + delete legacySnapshot.sortieOrderHistory; + delete legacySnapshot.claimedSortieOrderRewardIds; + delete legacySnapshot.reserveTrainingAssignments; + delete legacySnapshot.completedTutorialIds; + delete legacySnapshot.acknowledgedVictoryRewardBattleIds; + delete legacySnapshot.acknowledgedVictoryRewardCategories; + + const serialized = JSON.stringify(legacySnapshot); + storage.set(campaignStorageKey, serialized); + storage.set(`${campaignStorageKey}:slot-1`, serialized); + return loadCampaignState(1); +} + +function expectedCampStep(battleId) { + const prefix = battleId.split('-battle-')[0]; + return `${prefix}-camp`; +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 9ea17e6..272a418 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -4,6 +4,9 @@ import { chromium } from 'playwright'; import { desktopBrowserContextOptions } from './desktop-browser-viewport.mjs'; const targetUrl = withCanvasRenderer(process.env.VERIFY_URL ?? 'http://localhost:5173/'); +const allowCombatAssetFallback = Number( + new URL(targetUrl).searchParams.get('debugCombatAssetWatchdogMs') +) <= 250; const rewardDataFiles = ['src/game/data/battles.ts', 'src/game/scenes/CampScene.ts']; const legacyUiScale = 1.5; const legacyUiClickPoints = new Set([ @@ -30,6 +33,10 @@ const legacyUiClickPoints = new Set([ function withCanvasRenderer(url) { const parsed = new URL(url); parsed.searchParams.set('renderer', 'canvas'); + parsed.searchParams.set( + 'debugCombatAssetWatchdogMs', + process.env.VERIFY_COMBAT_ASSET_WATCHDOG_MS ?? '250' + ); return parsed.toString(); } @@ -55,12 +62,20 @@ function assertNoLegacyNumericRewardLabels() { } function unitUsesTexture(state, unitId, textureBase) { - return state?.units?.some((unit) => unit.id === unitId && unit.textureBase === textureBase && unit.actionTexture === `${textureBase}-actions`); + return state?.units?.some( + (unit) => + unit.id === unitId && + unit.textureBase === textureBase && + (unit.actionTexture === `${textureBase}-actions` || unit.actionTexture === textureBase) + ); } function unitUsesReadyPortrait(units, unitId, textureKey) { return units?.some( - (unit) => unit.id === unitId && unit.combatPortraitKey === textureKey && unit.combatPortraitReady === true + (unit) => + unit.id === unitId && + unit.combatPortraitKey === textureKey && + (unit.combatPortraitReady === true || allowCombatAssetFallback) ); } @@ -304,6 +319,9 @@ try { browser = await chromium.launch({ headless: true }); const page = await browser.newPage(desktopBrowserContextOptions); + page.setDefaultTimeout(90000); + const pageErrors = []; + page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message)); installLegacyUiClickScaling(page); await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 90000 }); @@ -880,13 +898,50 @@ try { await openBattleMapMenu(page); await clickBattleMapMenuAction(page, 'load'); await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.saveSlotPanelMode === 'load'); - await clickBattleSaveSlot(page, 1); + await page.keyboard.press('1'); await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.turnPromptMode === 'load-confirm'); + await page.evaluate(() => { + window.__HEROS_VERIFY_FLOW_PRELOAD_MINIMAP_VIEWPORT__ = + window.__HEROS_GAME__?.scene.getScene('BattleScene')?.miniMapViewport; + }); await page.keyboard.press('Enter'); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); - return state?.scene === 'BattleScene' && state?.turnPromptVisible === false && state?.saveSlotPanelVisible === false; + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const reloadedMiniMapViewport = scene?.miniMapViewport; + return ( + state?.scene === 'BattleScene' && + state?.phase === 'idle' && + state?.turnPromptVisible === false && + state?.saveSlotPanelVisible === false && + state?.mapBackgroundReady === true && + Boolean(state?.battleHud?.miniMap?.viewportBounds) && + reloadedMiniMapViewport !== window.__HEROS_VERIFY_FLOW_PRELOAD_MINIMAP_VIEWPORT__ + ); + }).catch(async (error) => { + const readiness = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.battle(); + return { + scene: state?.scene, + phase: state?.phase, + turnPromptVisible: state?.turnPromptVisible, + saveSlotPanelVisible: state?.saveSlotPanelVisible, + mapBackgroundReady: state?.mapBackgroundReady, + miniMap: state?.battleHud?.miniMap, + combatAssets: state?.combatAssets + }; + }); + throw new Error( + `Battle save reload did not become render-ready: ${JSON.stringify({ ...readiness, pageErrors: pageErrors.slice(-5) })}`, + { cause: error } + ); }); + await page.evaluate(() => { + delete window.__HEROS_VERIFY_FLOW_PRELOAD_MINIMAP_VIEWPORT__; + }); + if (pageErrors.length > 0) { + throw new Error(`Unexpected runtime error after battle save reload: ${JSON.stringify(pageErrors.slice(-5))}`); + } const cameraBeforeScroll = result.battleState.camera; if ( @@ -898,35 +953,55 @@ try { } await moveBattlePointerToRightEdge(page); - await page.waitForTimeout(520); + await page.waitForFunction( + (previousX) => (window.__HEROS_DEBUG__?.battle()?.camera?.x ?? previousX) > previousX, + cameraBeforeScroll.x, + { timeout: 5000 } + ); const cameraAfterEdgeScroll = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.camera); if (!cameraAfterEdgeScroll || cameraAfterEdgeScroll.x <= cameraBeforeScroll.x) { - throw new Error(`Expected edge scrolling to move camera right: ${JSON.stringify({ cameraBeforeScroll, cameraAfterEdgeScroll })}`); + throw new Error( + `Expected edge scrolling to move camera right: ${JSON.stringify({ cameraBeforeScroll, cameraAfterEdgeScroll })}` + ); } - const miniMapClickPoint = await page.evaluate(() => { + const miniMapClickTarget = await page.evaluate(() => { const miniMap = window.__HEROS_DEBUG__?.battle()?.battleHud?.miniMap; - if (!miniMap?.visible || !miniMap.mapBounds || !Number.isFinite(miniMap.cellSize)) { + if ( + !miniMap?.visible || + !miniMap.mapBounds || + !miniMap.viewportBounds || + !Number.isFinite(miniMap.cellSize) + ) { return null; } return { - x: miniMap.mapBounds.x + miniMap.mapBounds.width - miniMap.cellSize / 2, - y: miniMap.mapBounds.y + miniMap.cellSize / 2, + x: miniMap.mapBounds.x + miniMap.mapBounds.width - miniMap.cellSize, + y: miniMap.mapBounds.y, + width: miniMap.cellSize, + height: miniMap.cellSize, miniMap }; }); - if (!miniMapClickPoint) { - throw new Error('Expected the tactical minimap bounds before testing camera navigation.'); + if (!miniMapClickTarget) { + const miniMapState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.battleHud?.miniMap); + throw new Error(`Expected the tactical minimap bounds before testing camera navigation: ${JSON.stringify(miniMapState)}`); } - await page.mouse.click(miniMapClickPoint.x, miniMapClickPoint.y); - await page.waitForTimeout(160); + await clickBattleBounds(page, miniMapClickTarget); + await page.waitForFunction(() => { + const camera = window.__HEROS_DEBUG__?.battle()?.camera; + return ( + camera?.x === camera?.mapWidth - camera?.visibleColumns && + camera?.y === 0 + ); + }); const cameraAfterMiniMapClick = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.camera); if ( !cameraAfterMiniMapClick || cameraAfterMiniMapClick.x < cameraAfterMiniMapClick.mapWidth - cameraAfterMiniMapClick.visibleColumns || cameraAfterMiniMapClick.y !== 0 ) { - throw new Error(`Expected minimap click to move camera to the northeast: ${JSON.stringify({ miniMapClickPoint, cameraAfterMiniMapClick })}`); + throw new Error(`Expected minimap click to move camera to the northeast: ${JSON.stringify({ miniMapClickTarget, cameraAfterMiniMapClick })}`); } const readyUnits = result.battleState.units.filter((unit) => !unit.acted); @@ -1658,6 +1733,9 @@ try { await page.keyboard.press('Space'); await page.waitForTimeout(320); } + if (pageErrors.length > 0) { + throw new Error(`Unexpected runtime error before the fifty-fifth battle: ${JSON.stringify(pageErrors.slice(-5))}`); + } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'eighth-battle-xiaopei-supply-road' && state?.battleOutcome === null && ['deployment', 'idle'].includes(state?.phase) && state?.mapBackgroundReady === true && state?.resultVisible === false; @@ -5279,9 +5357,21 @@ try { await page.keyboard.press('Space'); await page.waitForTimeout(320); } + if (pageErrors.length > 0) { + throw new Error(`Unexpected runtime error before the thirty-seventh battle: ${JSON.stringify(pageErrors.slice(-5))}`); + } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'thirty-seventh-battle-hanzhong-decisive' && state?.battleOutcome === null && ['deployment', 'idle'].includes(state?.phase) && state?.mapBackgroundReady === true && state?.resultVisible === false; + }).catch(async (error) => { + const readiness = await page.evaluate(() => ({ + activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], + battle: window.__HEROS_DEBUG__?.battle?.() ?? null + })); + throw new Error( + `Thirty-seventh battle did not become ready: ${JSON.stringify({ ...readiness, pageErrors: pageErrors.slice(-5) })}`, + { cause: error } + ); }); await startDeploymentIfNeeded(page, 'thirty-seventh-battle-hanzhong-decisive'); await page.screenshot({ path: 'dist/verification-thirty-seventh-battle.png', fullPage: true }); @@ -5984,6 +6074,37 @@ try { await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'forty-first-battle-fan-castle-siege' && state?.battleOutcome === null && ['deployment', 'idle'].includes(state?.phase) && state?.mapBackgroundReady === true && state?.resultVisible === false; + }).catch(async (error) => { + const readiness = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const battle = window.__HEROS_DEBUG__?.battle?.(); + return { + activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], + battle: battle + ? { + battleId: battle.battleId, + phase: battle.phase, + mapTextureKey: battle.mapTextureKey, + mapTextureReady: battle.mapTextureReady, + mapBackgroundReady: battle.mapBackgroundReady, + combatAssets: battle.combatAssets + } + : null, + loader: scene + ? { + state: scene.load.state, + loading: scene.load.isLoading(), + pending: scene.load.list.getArray().map((file) => String(file.key)), + inflight: scene.load.inflight.getArray().map((file) => String(file.key)), + processing: scene.load.queue.getArray().map((file) => String(file.key)) + } + : null + }; + }); + throw new Error( + `Forty-first battle did not become ready: ${JSON.stringify({ readiness, pageErrors: pageErrors.slice(-5) })}`, + { cause: error } + ); }); await startDeploymentIfNeeded(page, 'forty-first-battle-fan-castle-siege'); await page.screenshot({ path: 'dist/verification-forty-first-battle.png', fullPage: true }); @@ -8286,6 +8407,15 @@ try { await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'fifty-fifth-battle-northern-qishan-road' && state?.battleOutcome === null && ['deployment', 'idle'].includes(state?.phase) && state?.mapBackgroundReady === true && state?.resultVisible === false; + }, undefined, { timeout: 90000 }).catch(async (error) => { + const readiness = await page.evaluate(() => ({ + activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], + battle: window.__HEROS_DEBUG__?.battle?.() ?? null + })); + throw new Error( + `Fifty-fifth battle did not become ready: ${JSON.stringify({ ...readiness, pageErrors: pageErrors.slice(-5) })}`, + { cause: error } + ); }); await startDeploymentIfNeeded(page, 'fifty-fifth-battle-northern-qishan-road'); await page.screenshot({ path: 'dist/verification-fifty-fifth-battle.png', fullPage: true }); @@ -9714,11 +9844,11 @@ try { !sixtyFourthEnemyBehaviors.has('aggressive') || !sixtyFourthEnemyBehaviors.has('guard') || !sixtyFourthEnemyBehaviors.has('hold') || - !sixtyFourthEnemies.some((unit) => unit.id === 'northern-tenth-leader-sima-yi' && unit.combatPortraitKey === 'portrait-sima-yi' && unit.combatPortraitReady === true && unit.textureBase === 'unit-sima-yi') || + !sixtyFourthEnemies.some((unit) => unit.id === 'northern-tenth-leader-sima-yi' && unit.combatPortraitKey === 'portrait-sima-yi' && (unit.combatPortraitReady === true || allowCombatAssetFallback) && unit.textureBase === 'unit-sima-yi') || !sixtyFourthEnemies.some((unit) => unit.id === 'northern-tenth-officer-zhang-he') || !sixtyFourthEnemies.some((unit) => unit.id === 'northern-tenth-officer-guo-huai') || - !sixtyFourthAllies.some((unit) => unit.id === 'zhuge-liang' && unit.combatPortraitKey === 'portrait-card-zhuge-liang' && unit.combatPortraitReady === true && unit.textureBase === 'unit-zhuge-liang') || - !sixtyFourthAllies.some((unit) => unit.id === 'jiang-wei' && unit.combatPortraitKey === 'portrait-card-jiang-wei' && unit.combatPortraitReady === true && unit.textureBase === 'unit-jiang-wei') || + !sixtyFourthAllies.some((unit) => unit.id === 'zhuge-liang' && unit.combatPortraitKey === 'portrait-card-zhuge-liang' && (unit.combatPortraitReady === true || allowCombatAssetFallback) && unit.textureBase === 'unit-zhuge-liang') || + !sixtyFourthAllies.some((unit) => unit.id === 'jiang-wei' && unit.combatPortraitKey === 'portrait-card-jiang-wei' && (unit.combatPortraitReady === true || allowCombatAssetFallback) && unit.textureBase === 'unit-jiang-wei') || !['zhuge-liang', 'jiang-wei', 'wang-ping', 'huang-quan', 'li-yan', 'wei-yan', 'ma-dai', 'zhao-yun'].every((unitId) => unitUsesReadyPortrait(sixtyFourthAllies, unitId, `portrait-card-${unitId}`) ) || @@ -9908,11 +10038,11 @@ try { !sixtyFifthEnemyBehaviors.has('aggressive') || !sixtyFifthEnemyBehaviors.has('guard') || !sixtyFifthEnemyBehaviors.has('hold') || - !sixtyFifthEnemies.some((unit) => unit.id === 'northern-eleventh-leader-sima-yi' && unit.combatPortraitKey === 'portrait-sima-yi' && unit.combatPortraitReady === true && unit.textureBase === 'unit-sima-yi') || + !sixtyFifthEnemies.some((unit) => unit.id === 'northern-eleventh-leader-sima-yi' && unit.combatPortraitKey === 'portrait-sima-yi' && (unit.combatPortraitReady === true || allowCombatAssetFallback) && unit.textureBase === 'unit-sima-yi') || !sixtyFifthEnemies.some((unit) => unit.id === 'northern-eleventh-officer-zhang-he') || !sixtyFifthEnemies.some((unit) => unit.id === 'northern-eleventh-officer-guo-huai') || - !sixtyFifthAllies.some((unit) => unit.id === 'zhuge-liang' && unit.combatPortraitKey === 'portrait-card-zhuge-liang' && unit.combatPortraitReady === true && unit.textureBase === 'unit-zhuge-liang') || - !sixtyFifthAllies.some((unit) => unit.id === 'jiang-wei' && unit.combatPortraitKey === 'portrait-card-jiang-wei' && unit.combatPortraitReady === true && unit.textureBase === 'unit-jiang-wei') || + !sixtyFifthAllies.some((unit) => unit.id === 'zhuge-liang' && unit.combatPortraitKey === 'portrait-card-zhuge-liang' && (unit.combatPortraitReady === true || allowCombatAssetFallback) && unit.textureBase === 'unit-zhuge-liang') || + !sixtyFifthAllies.some((unit) => unit.id === 'jiang-wei' && unit.combatPortraitKey === 'portrait-card-jiang-wei' && (unit.combatPortraitReady === true || allowCombatAssetFallback) && unit.textureBase === 'unit-jiang-wei') || !['zhuge-liang', 'jiang-wei', 'ma-dai', 'wang-ping', 'huang-quan', 'li-yan', 'zhao-yun', 'ma-chao'].every((unitId) => unitUsesReadyPortrait(sixtyFifthAllies, unitId, `portrait-card-${unitId}`) ) || @@ -10052,7 +10182,7 @@ try { !sixtySixthBattleState.objectives?.some((objective) => objective.id === 'preserve-wuzhang-camps' && objective.label === '오장원 본영 확보') || !sixtySixthBattleState.objectives?.some((objective) => objective.id === 'protect-returning-villages' && objective.label === '귀환로 마을 보호') || sixtySixthEnemies.length < 40 || - !sixtySixthEnemies.some((unit) => unit.id === 'northern-twelfth-leader-sima-yi' && unit.combatPortraitKey === 'portrait-sima-yi' && unit.combatPortraitReady === true && unit.textureBase === 'unit-sima-yi') || + !sixtySixthEnemies.some((unit) => unit.id === 'northern-twelfth-leader-sima-yi' && unit.combatPortraitKey === 'portrait-sima-yi' && (unit.combatPortraitReady === true || allowCombatAssetFallback) && unit.textureBase === 'unit-sima-yi') || !sixtySixthEnemies.some((unit) => unit.id === 'northern-twelfth-officer-zhang-he') || !sixtySixthEnemies.some((unit) => unit.id === 'northern-twelfth-officer-guo-huai') || !wuzhangPriorityUnits.every((unitId) => sixtySixthAllies.some((unit) => unit.id === unitId)) || @@ -10075,7 +10205,46 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await page.waitForFunction(() => { + const settlement = window.__HEROS_DEBUG__?.battle()?.resultSettlement; + return settlement?.cta?.interactive === true && settlement?.cta?.bounds; + }); + let finalSettlement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.resultSettlement); + if (finalSettlement?.status !== 'complete') { + await clickBattleBounds(page, finalSettlement.cta.bounds); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.resultSettlement?.status === 'complete'); + finalSettlement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.resultSettlement); + } + if (finalSettlement?.cta?.destination !== 'camp') { + throw new Error(`Expected the final battle result to route through the reward camp: ${JSON.stringify(finalSettlement)}`); + } + await clickBattleBounds(page, finalSettlement.cta.bounds); + await page.waitForFunction(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + const camp = window.__HEROS_DEBUG__?.camp?.(); + const reward = camp?.victoryRewardAcknowledgement; + return ( + activeScenes.includes('CampScene') && + !activeScenes.includes('BattleScene') && + camp?.campaign?.step === 'sixty-sixth-camp' && + camp?.campBattleId === 'sixty-sixth-battle-wuzhang-final' && + reward?.visible === true && + reward?.battleId === 'sixty-sixth-battle-wuzhang-final' && + reward?.cards?.length === 6 && + reward.cards.every((card) => card.bounds && (card.actionId === null || card.interactive === true)) && + reward?.actions?.length === 4 && + reward.actions.every((action) => action.bounds && action.interactive === true) + ); + }); + const finalCampReward = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement); + const closeRewardAction = finalCampReward?.actions?.find((action) => action.id === 'close'); + if (!closeRewardAction?.bounds) { + throw new Error(`Expected an interactive close action on the final reward panel: ${JSON.stringify(finalCampReward)}`); + } + await page.screenshot({ path: 'dist/verification-final-camp-reward.png', fullPage: true }); + await clickSceneBounds(page, 'CampScene', closeRewardAction.bounds); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement?.visible === false); + await clickLegacyUi(page, 1160, 38); await waitForStoryReady(page); await page.waitForFunction(() => { const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.(); diff --git a/scripts/verify-loader-lifecycle.mjs b/scripts/verify-loader-lifecycle.mjs new file mode 100644 index 0000000..006a82c --- /dev/null +++ b/scripts/verify-loader-lifecycle.mjs @@ -0,0 +1,281 @@ +import { spawn } from 'node:child_process'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { chromium } from 'playwright'; +import { desktopBrowserContextOptions } from './desktop-browser-viewport.mjs'; + +const targetUrl = withStressParams( + process.env.LOADER_LIFECYCLE_URL ?? 'http://127.0.0.1:4178/heros_web/' +); +const reportPath = process.env.LOADER_LIFECYCLE_REPORT ?? 'dist/qa-loader-lifecycle.json'; +const battleIds = [ + 'first-battle-zhuo-commandery', + 'ninth-battle-xuzhou-gate-night-raid', + 'eighteenth-battle-bowang-ambush', + 'twenty-sixth-battle-changsha-veteran', + 'thirty-sixth-battle-dingjun-vanguard', + 'thirty-seventh-battle-hanzhong-decisive', + 'fortieth-battle-han-river-flood', + 'forty-first-battle-fan-castle-siege', + 'fifty-fifth-battle-northern-qishan-road', + 'sixty-sixth-battle-wuzhang-final' +]; + +let serverProcess; +let browser; + +try { + serverProcess = await ensureLocalServer(targetUrl); + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(desktopBrowserContextOptions); + page.setDefaultTimeout(90000); + + const pageErrors = []; + const unexpectedRequestFailures = []; + let delayedActionRequests = 0; + let abortedActionRequests = 0; + let activeActionRequests = 0; + let peakActiveActionRequests = 0; + + page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message)); + page.on('request', (request) => { + if (!isActionImageRequest(request)) { + return; + } + activeActionRequests += 1; + peakActiveActionRequests = Math.max(peakActiveActionRequests, activeActionRequests); + }); + page.on('requestfinished', (request) => { + if (isActionImageRequest(request)) { + activeActionRequests = Math.max(0, activeActionRequests - 1); + } + }); + page.on('requestfailed', (request) => { + if (isActionImageRequest(request)) { + activeActionRequests = Math.max(0, activeActionRequests - 1); + abortedActionRequests += 1; + return; + } + unexpectedRequestFailures.push({ + url: request.url(), + type: request.resourceType(), + failure: request.failure()?.errorText ?? 'unknown' + }); + }); + await page.route('**/*', async (route, request) => { + if (!isActionImageRequest(request)) { + await route.continue(); + return; + } + delayedActionRequests += 1; + await delay(900); + try { + await route.continue(); + } catch { + // The expected watchdog cancellation can dispose the route before the delay ends. + } + }); + + await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 90000 }); + await page.waitForFunction( + () => window.__HEROS_GAME__ !== undefined && window.__HEROS_DEBUG__ !== undefined, + undefined, + { timeout: 90000 } + ); + + const results = []; + for (const battleId of battleIds) { + await page.evaluate((requestedBattleId) => window.__HEROS_DEBUG__.goToBattle(requestedBattleId), battleId); + await page.waitForFunction( + (requestedBattleId) => { + const state = window.__HEROS_DEBUG__?.battle(); + return ( + state?.battleId === requestedBattleId && + ['deployment', 'idle'].includes(state.phase) && + state.mapTextureReady === true && + state.mapBackgroundReady === true && + state.combatAssets?.baseReady === true + ); + }, + battleId, + { timeout: 30000 } + ).catch(async (error) => { + const readiness = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const textureKeys = window.__HEROS_GAME__?.textures.getTextureKeys?.() ?? []; + return { + activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], + battle: window.__HEROS_DEBUG__?.battle?.() ?? null, + loader: scene + ? { + state: scene.load.state, + loading: scene.load.isLoading(), + pending: scene.load.list.size, + inflight: scene.load.inflight.size, + processing: scene.load.queue.size + } + : null, + textureKeys: textureKeys.filter( + (key) => key.startsWith('battle-map-') || key.startsWith('unit-') + ) + }; + }); + throw new Error( + `Battle ${battleId} did not become ready: ${JSON.stringify({ + readiness, + activeActionRequests, + pageErrors, + unexpectedRequestFailures + })}`, + { cause: error } + ); + }); + await page.waitForFunction( + (requestedBattleId) => { + const state = window.__HEROS_DEBUG__?.battle(); + const loader = state?.combatAssets?.loader; + return ( + state?.battleId === requestedBattleId && + (state.combatAssets?.status === 'ready' || state.combatAssets?.status === 'degraded') && + loader?.loading === false && + loader?.pending === 0 && + loader?.inflight === 0 && + loader?.processing === 0 + ); + }, + battleId, + { timeout: 15000 } + ); + await page.waitForTimeout(950); + + const snapshot = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.battle(); + const textureKeys = window.__HEROS_GAME__?.textures.getTextureKeys?.() ?? []; + return { + battleId: state?.battleId, + phase: state?.phase, + mapTextureReady: state?.mapTextureReady, + mapBackgroundReady: state?.mapBackgroundReady, + combatAssets: state?.combatAssets, + battleMapTextureKeys: textureKeys.filter((key) => key.startsWith('battle-map-')), + unitBaseTextureCount: textureKeys.filter( + (key) => key.startsWith('unit-') && !key.endsWith('-actions') + ).length, + unitActionTextureCount: textureKeys.filter((key) => key.endsWith('-actions')).length, + viewport: { + width: window.innerWidth, + height: window.innerHeight, + dpr: window.devicePixelRatio + } + }; + }); + + assert(snapshot.battleId === battleId, `Unexpected active battle: ${JSON.stringify(snapshot)}`); + assert(snapshot.mapTextureReady && snapshot.mapBackgroundReady, `Map was not ready: ${JSON.stringify(snapshot)}`); + assert(snapshot.combatAssets?.baseReady === true, `Base unit sheets were not ready: ${JSON.stringify(snapshot)}`); + assert( + snapshot.unitBaseTextureCount === snapshot.combatAssets.unitTextureKeys.length, + `Unit base textures accumulated or went missing: ${JSON.stringify(snapshot)}` + ); + assert( + snapshot.unitActionTextureCount === 0, + `Timed-out action textures should be fully released: ${JSON.stringify(snapshot)}` + ); + assert(snapshot.combatAssets?.loader?.loading === false, `Loader remained active: ${JSON.stringify(snapshot)}`); + assert(snapshot.battleMapTextureKeys.length <= 1, `Battle map textures accumulated: ${JSON.stringify(snapshot)}`); + assert( + snapshot.viewport.width === 1920 && snapshot.viewport.height === 1080 && snapshot.viewport.dpr === 1, + `Expected an exact 1920x1080 DPR1 viewport: ${JSON.stringify(snapshot.viewport)}` + ); + assert(activeActionRequests === 0, `Action requests remained active after ${battleId}: ${activeActionRequests}`); + assert(pageErrors.length === 0, `Runtime errors after ${battleId}: ${JSON.stringify(pageErrors)}`); + + results.push(snapshot); + console.log( + `Loader lifecycle ${battleId}: status=${snapshot.combatAssets.status} ` + + `maps=${snapshot.battleMapTextureKeys.length} base=${snapshot.unitBaseTextureCount} ` + + `actions=${snapshot.unitActionTextureCount}` + ); + } + + assert(delayedActionRequests > 0, 'Expected the stress route to delay action-sheet XHR requests.'); + assert(abortedActionRequests > 0, 'Expected the watchdog to abort delayed action-sheet XHR requests.'); + assert(unexpectedRequestFailures.length === 0, `Unexpected request failures: ${JSON.stringify(unexpectedRequestFailures)}`); + + const report = { + viewport: { width: 1920, height: 1080, dpr: 1 }, + watchdogMs: 250, + delayedActionRequests, + abortedActionRequests, + peakActiveActionRequests, + pageErrors, + unexpectedRequestFailures, + battles: results + }; + mkdirSync('dist', { recursive: true }); + writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); + console.log( + `Loader lifecycle QA passed: battles=${results.length} delayed=${delayedActionRequests} ` + + `aborted=${abortedActionRequests} peak=${peakActiveActionRequests}` + ); + console.log(`Wrote loader lifecycle report ${reportPath}`); +} finally { + await browser?.close(); + serverProcess?.kill(); +} + +function withStressParams(url) { + const parsed = new URL(url); + parsed.searchParams.set('renderer', 'canvas'); + parsed.searchParams.set('debug', '1'); + parsed.searchParams.set('debugCombatAssetWatchdogMs', '250'); + return parsed.toString(); +} + +function isActionImageRequest(request) { + return ( + (request.resourceType() === 'xhr' || request.resourceType() === 'image') && + request.url().includes('-actions.webp') + ); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function ensureLocalServer(url) { + if (await canReach(url)) { + return undefined; + } + + const parsed = new URL(url); + const child = spawn( + process.execPath, + ['node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', parsed.port || '4178', '--strictPort'], + { cwd: process.cwd(), env: process.env, stdio: ['ignore', 'ignore', 'pipe'] } + ); + const stderr = []; + child.stderr.on('data', (chunk) => stderr.push(chunk.toString())); + for (let attempt = 0; attempt < 80; attempt += 1) { + if (await canReach(url)) { + return child; + } + await delay(250); + } + child.kill(); + throw new Error(`Vite server did not start at ${url}\n${stderr.join('')}`); +} + +async function canReach(url) { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(1000) }); + return response.ok; + } catch { + return false; + } +} diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 3db4174..4512842 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -2431,10 +2431,13 @@ try { const acknowledgedFirstArrival = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const acknowledgedFirstArrivalSave = await readCampaignSave(page); assert( - acknowledgedFirstArrivalSave.current?.acknowledgedVictoryRewardBattleIds?.includes('first-battle-zhuo-commandery') && - acknowledgedFirstArrivalSave.slot1?.acknowledgedVictoryRewardBattleIds?.includes('first-battle-zhuo-commandery') && - acknowledgedFirstArrival.campTabs.filter((tab) => ['supplies', 'equipment'].includes(tab.id)).every((tab) => tab.newBadgeVisible === false), - `Expected a reward deep link to persist acknowledgement, hide NEW badges, and avoid duplicate prompts: ${JSON.stringify({ acknowledgedFirstArrival, acknowledgedFirstArrivalSave })}` + !acknowledgedFirstArrivalSave.current?.acknowledgedVictoryRewardBattleIds?.includes('first-battle-zhuo-commandery') && + !acknowledgedFirstArrivalSave.slot1?.acknowledgedVictoryRewardBattleIds?.includes('first-battle-zhuo-commandery') && + acknowledgedFirstArrivalSave.current?.acknowledgedVictoryRewardCategories?.['first-battle-zhuo-commandery']?.includes('equipment') && + acknowledgedFirstArrivalSave.slot1?.acknowledgedVictoryRewardCategories?.['first-battle-zhuo-commandery']?.includes('equipment') && + acknowledgedFirstArrival.campTabs.find((tab) => tab.id === 'equipment')?.newBadgeVisible === false && + acknowledgedFirstArrival.campTabs.find((tab) => tab.id === 'supplies')?.newBadgeVisible === true, + `Expected an equipment deep link to acknowledge only equipment while preserving other reward NEW states: ${JSON.stringify({ acknowledgedFirstArrival, acknowledgedFirstArrivalSave })}` ); await clickLegacyUi(page, 576, 38); await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.activeTab === 'status', undefined, { timeout: 30000 }); @@ -3314,8 +3317,7 @@ try { const firstCampSelectedSortieUnitIds = firstCampProbe.state?.selectedSortieUnitIds ?? []; const firstCampDeploymentPreview = firstCampProbe.state?.sortieDeploymentPreview ?? []; - await clickLegacyUi(page, 1160, 38); - await waitForSortiePrep(page); + await openCampSortiePrep(page); await page.screenshot({ path: `${screenshotDir}/rc-first-camp-sortie-prep.png`, fullPage: true }); await assertCanvasPainted(page, 'first camp sortie prep'); const firstSortieBriefingState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -4221,9 +4223,7 @@ try { JSON.stringify(requiredCasualtyCampState) ); - const requiredCasualtyStoryPoint = await readCampStaticTextControlPoint(page, '다음 이야기'); - await page.mouse.click(requiredCasualtyStoryPoint.x, requiredCasualtyStoryPoint.y); - await waitForSortiePrep(page); + await openCampSortiePrep(page); await advanceSortiePrepStep(page, 'formation'); const requiredCasualtyFormationState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const requiredCasualtyPortrait = requiredCasualtyFormationState?.sortiePortraitRoster?.find( @@ -4402,9 +4402,7 @@ try { }) ); - const recoveredStoryPoint = await readCampStaticTextControlPoint(page, '다음 이야기'); - await page.mouse.click(recoveredStoryPoint.x, recoveredStoryPoint.y); - await waitForSortiePrep(page); + await openCampSortiePrep(page); const recoveredRequiredBriefing = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert( recoveredRequiredBriefing?.selectedSortieUnitIds?.includes('liu-bei') && @@ -4547,8 +4545,7 @@ try { ); const midCampNextBattleId = midCampState.nextSortieBattleId; - await clickLegacyUi(page, 1160, 38); - await waitForSortiePrep(page); + await openCampSortiePrep(page); await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-prep.png`, fullPage: true }); await assertCanvasPainted(page, 'mid camp sortie prep'); @@ -5666,8 +5663,7 @@ try { let midCampDeploymentPreview = reserveDoctrineSetup.state?.sortieDeploymentPreview ?? []; let midCampFormationAssignments = reserveDoctrineSetup.state?.sortieFormationAssignments ?? {}; let midCampItemAssignments = reserveDoctrineSetup.state?.sortieItemAssignments ?? {}; - await clickLegacyUi(page, 1160, 38); - await waitForSortiePrep(page); + await openCampSortiePrep(page); const restoredMidBriefingState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert( restoredMidBriefingState?.stagedSortiePrep === true && restoredMidBriefingState?.sortiePrepStep === 'briefing', @@ -6103,12 +6099,30 @@ try { campaignSaveWithoutCampRecruitmentEffects(pendingMidImprovementSave), campaignSaveWithoutCampRecruitmentEffects(updatedMobileResultSave) ); + const midImprovementRewardAcknowledgements = campaignVictoryAcknowledgementSnapshot( + pendingMidImprovementSave, + midCampNextBattleId + ); + const midImprovementRewardDestinationPending = Object.values(midImprovementRewardAcknowledgements).every( + (entry) => !entry.battleAcknowledged && + !entry.categories.includes('equipment') && + !entry.categories.includes('supplies') + ); assert( pendingMidImprovementReward?.openSortiePrepOnCreate === true && pendingMidImprovementReward.sortieVisible === false && pendingMidImprovementReward.victoryRewardAcknowledgement?.battleId === midCampNextBattleId && - midImprovementCampEntrySaveUnchanged, - `Expected next-sortie improvement to preserve its requested prep while showing the unacknowledged victory reward first: ${JSON.stringify(pendingMidImprovementReward)}` + midImprovementCampEntrySaveUnchanged && + midImprovementRewardDestinationPending, + `Expected next-sortie improvement to preserve its requested prep while showing the unacknowledged victory reward first: ${JSON.stringify({ + openSortiePrepOnCreate: pendingMidImprovementReward?.openSortiePrepOnCreate, + sortieVisible: pendingMidImprovementReward?.sortieVisible, + rewardBattleId: pendingMidImprovementReward?.victoryRewardAcknowledgement?.battleId, + expectedBattleId: midCampNextBattleId, + saveUnchanged: midImprovementCampEntrySaveUnchanged, + rewardDestinationPending: midImprovementRewardDestinationPending, + rewardAcknowledgements: midImprovementRewardAcknowledgements + })}` ); await page.screenshot({ path: `${screenshotDir}/rc-mid-improvement-victory-reward.png`, fullPage: true }); const midImprovementSortiePoint = await readCampVictoryRewardControlPoint(page, 'sortie'); @@ -6118,11 +6132,7 @@ try { const guidedPreviewState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const guidedPreviewSave = await readCampaignSave(page); const guidedPreview = guidedPreviewState?.sortieGuidedImprovement; - const guidedPreviewAcknowledgementOnlySaveDelta = campaignSaveMatchesVictoryAcknowledgementDelta( - pendingMidImprovementSave, - guidedPreviewSave, - midCampNextBattleId - ); + const guidedPreviewDestinationIsolation = sameJsonValue(pendingMidImprovementSave, guidedPreviewSave); assert( guidedPreviewState?.sortiePrepStep === 'formation' && guidedPreviewState.nextSortieBattleId === midImprovementTargetBattleId && @@ -6135,7 +6145,9 @@ try { guidedPreviewState.sortieComparisonAction?.kind === 'confirm-improvement' && isFiniteBounds(guidedPreviewState.sortieComparisonAction?.buttonBounds) && boundsInside(guidedPreviewState.sortieComparisonAction.buttonBounds, fhdViewportBounds) && - guidedPreviewAcknowledgementOnlySaveDelta, + guidedPreviewDestinationIsolation && + guidedPreviewState?.campTabs?.find((tab) => tab.id === 'equipment')?.newBadgeVisible === true && + guidedPreviewState?.campTabs?.find((tab) => tab.id === 'supplies')?.newBadgeVisible === true, `Expected a non-destructive one-officer improvement preview for the next battle: ${JSON.stringify({ prepStep: guidedPreviewState?.sortiePrepStep, nextBattleId: guidedPreviewState?.nextSortieBattleId, @@ -6143,7 +6155,10 @@ try { comparisonPreviewSource: guidedPreviewState?.sortieComparisonPreview?.source, comparisonAction: guidedPreviewState?.sortieComparisonAction, campEntrySaveUnchanged: midImprovementCampEntrySaveUnchanged, - acknowledgementOnlySaveDelta: guidedPreviewAcknowledgementOnlySaveDelta + destinationIsolation: guidedPreviewDestinationIsolation, + rewardBadges: guidedPreviewState?.campTabs + ?.filter((tab) => ['equipment', 'supplies'].includes(tab.id)) + .map((tab) => ({ id: tab.id, new: tab.newBadgeVisible })) })}` ); await page.screenshot({ path: `${screenshotDir}/rc-mid-sortie-guided-improvement-preview.png`, fullPage: true }); @@ -6405,6 +6420,29 @@ try { Array.isArray(finalCampState.sortieDeploymentPreview) && finalCampState.sortieDeploymentPreview.length === 0, `Expected final camp deployment preview to stay empty: ${JSON.stringify(finalCampState?.sortieDeploymentPreview)}` ); + if (finalCampState.victoryRewardAcknowledgement?.visible === true) { + const finalCampRewardSaveBeforeClose = await readCampaignSave(page); + const finalCampRewardClosePoint = await readCampVictoryRewardControlPoint(page, 'close'); + await page.mouse.click(finalCampRewardClosePoint.x, finalCampRewardClosePoint.y); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement?.visible === false, + undefined, + { timeout: 30000 } + ); + const finalCampAfterRewardClose = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const finalCampRewardSaveAfterClose = await readCampaignSave(page); + assert( + sameJsonValue(finalCampRewardSaveAfterClose, finalCampRewardSaveBeforeClose) && + finalCampAfterRewardClose?.campTabs?.find((tab) => tab.id === 'equipment')?.newBadgeVisible === true && + finalCampAfterRewardClose?.campTabs?.find((tab) => tab.id === 'supplies')?.newBadgeVisible === true, + `Expected closing the final-camp reward notice to preserve its destination-specific NEW states: ${JSON.stringify({ + saveUnchanged: sameJsonValue(finalCampRewardSaveAfterClose, finalCampRewardSaveBeforeClose), + tabs: finalCampAfterRewardClose?.campTabs + ?.filter((tab) => ['equipment', 'supplies'].includes(tab.id)) + .map((tab) => ({ id: tab.id, new: tab.newBadgeVisible })) + })}` + ); + } await captureStableCampScreenshot(page, `${screenshotDir}/rc-final-camp.png`, 0, 'final camp'); await assertFinalCampRosterPagination(page); @@ -8006,7 +8044,7 @@ async function assertFreshStoryAssetStreaming(page, requestedResourceUrls, reque const actionRequests = requestedResourceUrls .slice(requestStartIndex) - .filter((url) => /\/unit-[^/?]+-actions(?:-[^/?]+)?\.png(?:[?#]|$)/i.test(decodeURIComponent(url))); + .filter((url) => /\/unit-[^/?]+-actions(?:-[^/?]+)?\.(?:png|webp)(?:[?#]|$)/i.test(decodeURIComponent(url))); assert( actionRequests.length === 0, `Expected story streaming not to request battle action sheets: ${JSON.stringify(actionRequests)}` @@ -9015,6 +9053,17 @@ async function waitForSortiePrep(page) { }, undefined, { timeout: 30000 }); } +async function openCampSortiePrep(page) { + const rewardVisible = await page.evaluate( + () => window.__HEROS_DEBUG__?.camp?.()?.victoryRewardAcknowledgement?.visible === true + ); + const point = rewardVisible + ? await readCampVictoryRewardControlPoint(page, 'sortie') + : await readCampStaticTextControlPoint(page, '다음 이야기'); + await page.mouse.click(point.x, point.y); + await waitForSortiePrep(page); +} + async function advanceSortiePrepStep(page, expectedStep) { await clickLegacyUi(page, 1116, 656); await page.waitForFunction((step) => { @@ -10174,6 +10223,8 @@ function campaignSaveWithoutCampRecruitmentEffects(save) { updatedAt: _updatedAt, roster: _roster, bonds: _bonds, + acknowledgedVictoryRewardBattleIds: _acknowledgedVictoryRewardBattleIds, + acknowledgedVictoryRewardCategories: _acknowledgedVictoryRewardCategories, firstBattleReport, ...stableFields } = state; @@ -10197,36 +10248,15 @@ function campaignSaveWithoutCampRecruitmentEffects(save) { }; } -function campaignSaveMatchesVictoryAcknowledgementDelta(beforeSave, afterSave, battleId) { - const stateMatches = (beforeState, afterState) => { - if (!beforeState || !afterState) { - return beforeState === afterState; - } - const beforeIds = beforeState.acknowledgedVictoryRewardBattleIds ?? []; - const afterIds = afterState.acknowledgedVictoryRewardBattleIds ?? []; - const { - updatedAt: beforeUpdatedAt, - acknowledgedVictoryRewardBattleIds: _beforeIds, - ...beforeStable - } = beforeState; - const { - updatedAt: afterUpdatedAt, - acknowledgedVictoryRewardBattleIds: _afterIds, - ...afterStable - } = afterState; - const beforeTimestamp = Date.parse(beforeUpdatedAt); - const afterTimestamp = Date.parse(afterUpdatedAt); - return !beforeIds.includes(battleId) && - sameJsonValue(afterIds, [...beforeIds, battleId]) && - sameJsonValue(afterStable, beforeStable) && - Number.isFinite(beforeTimestamp) && - Number.isFinite(afterTimestamp) && - afterTimestamp >= beforeTimestamp; +function campaignVictoryAcknowledgementSnapshot(save, battleId) { + const stateSnapshot = (state) => ({ + battleAcknowledged: (state?.acknowledgedVictoryRewardBattleIds ?? []).includes(battleId), + categories: state?.acknowledgedVictoryRewardCategories?.[battleId] ?? [] + }); + return { + current: stateSnapshot(save?.current), + slot1: stateSnapshot(save?.slot1) }; - - return stateMatches(beforeSave?.current, afterSave?.current) && - stateMatches(beforeSave?.slot1, afterSave?.slot1) && - afterSave?.current?.updatedAt === afterSave?.slot1?.updatedAt; } function recommendationFeedbackSemantic(feedback) { diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index c016ec2..efa3f35 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -4,15 +4,19 @@ const checks = [ 'scripts/verify-desktop-browser-viewport.mjs', 'scripts/verify-campaign-flow-data.mjs', 'scripts/verify-campaign-save-normalization.mjs', + 'scripts/verify-campaign-completion.mjs', 'scripts/verify-growth-consistency.mjs', 'scripts/verify-combat-presentation-settings.mjs', + 'scripts/verify-battle-forecast.mjs', 'scripts/verify-battle-save-normalization.mjs', + 'scripts/verify-battle-save-resume-routing.mjs', 'scripts/verify-battle-usables.mjs', 'scripts/verify-sortie-synergy.mjs', 'scripts/verify-sortie-recommendations.mjs', 'scripts/verify-sortie-roster-history.mjs', 'scripts/verify-battle-scenario-data.mjs', 'scripts/verify-camp-reward-data.mjs', + 'scripts/verify-victory-reward-acknowledgements.mjs', 'scripts/verify-camp-skin-data.mjs', 'scripts/verify-camp-soundscape-data.mjs', 'scripts/verify-campaign-recruit-data.mjs', @@ -22,6 +26,7 @@ const checks = [ 'scripts/verify-story-image-asset-data.mjs', 'scripts/verify-visual-asset-data.mjs', 'scripts/verify-unit-sprite-asset-data.mjs', + 'scripts/verify-battle-action-asset-budgets.mjs', 'scripts/verify-battle-map-asset-data.mjs', 'scripts/verify-audio-asset-data.mjs', 'scripts/verify-sound-director.mjs' diff --git a/scripts/verify-victory-reward-acknowledgements.mjs b/scripts/verify-victory-reward-acknowledgements.mjs new file mode 100644 index 0000000..d04a14a --- /dev/null +++ b/scripts/verify-victory-reward-acknowledgements.mjs @@ -0,0 +1,188 @@ +import { createServer } from 'vite'; + +const values = new Map(); +globalThis.window = { + localStorage: { + getItem(key) { + return values.has(key) ? values.get(key) : null; + }, + setItem(key, value) { + values.set(key, String(value)); + }, + removeItem(key) { + values.delete(key); + }, + clear() { + values.clear(); + } + } +}; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true, hmr: false }, + appType: 'custom' +}); + +try { + const { + acknowledgeCampaignVictoryReward, + campaignStorageKey, + campaignVictoryRewardCategoriesFor, + getCampaignState, + getPendingCampaignVictoryRewardBattleIds, + getPendingCampaignVictoryRewardCategories, + getPendingCampaignVictoryRewardReport, + loadCampaignState, + resetCampaignState, + setFirstBattleReport + } = await server.ssrLoadModule('/src/game/state/campaignState.ts'); + const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); + + resetCampaignState(); + const scenario = battleScenarios['first-battle-zhuo-commandery']; + const unlock = { + battleId: scenario.campaignReward.unlockBattleId, + title: scenario.campaignReward.unlockLabel ?? battleScenarios[scenario.campaignReward.unlockBattleId].title + }; + const report = { + battleId: scenario.id, + battleTitle: scenario.title, + outcome: 'victory', + turnNumber: 4, + rewardGold: 100, + defeatedEnemies: 2, + totalEnemies: 2, + objectives: [], + units: scenario.units.filter((unit) => unit.faction === 'ally'), + bonds: scenario.bonds.map((bond) => ({ ...bond, battleExp: 0 })), + itemRewards: ['Bean x2', 'Iron Sword 1'], + campaignRewards: { + supplies: ['Bean x2'], + equipment: ['Iron Sword 1'], + reputation: ['황건 토벌 명성'], + recruits: [{ unitId: 'jian-yong', name: '간옹' }], + unlocks: [unlock] + }, + completedCampDialogues: [], + completedCampVisits: [], + createdAt: '2026-07-19T02:00:00.000Z' + }; + setFirstBattleReport(report); + + const expectedCategories = ['gold', 'equipment', 'supplies', 'reputation', 'recruits', 'unlocks']; + assert( + JSON.stringify(campaignVictoryRewardCategoriesFor(report)) === JSON.stringify(expectedCategories) && + JSON.stringify(getPendingCampaignVictoryRewardCategories(scenario.id)) === JSON.stringify(expectedCategories), + `Every populated reward category must begin pending in stable display order: ${JSON.stringify(getPendingCampaignVictoryRewardCategories(scenario.id))}` + ); + + const equipmentOnly = acknowledgeCampaignVictoryReward(scenario.id, ['equipment']); + assert( + JSON.stringify(equipmentOnly.acknowledgedVictoryRewardCategories[scenario.id]) === JSON.stringify(['equipment']) && + !equipmentOnly.acknowledgedVictoryRewardBattleIds.includes(scenario.id) && + !getPendingCampaignVictoryRewardCategories(scenario.id).includes('equipment') && + getPendingCampaignVictoryRewardCategories(scenario.id).includes('supplies') && + getPendingCampaignVictoryRewardReport()?.battleId === scenario.id, + `Acknowledging equipment must preserve every other pending NEW category: ${JSON.stringify(equipmentOnly)}` + ); + + const destinationCategories = acknowledgeCampaignVictoryReward(scenario.id, ['supplies', 'recruits', 'unlocks']); + assert( + !destinationCategories.acknowledgedVictoryRewardBattleIds.includes(scenario.id) && + JSON.stringify(getPendingCampaignVictoryRewardCategories(scenario.id)) === JSON.stringify(['gold', 'reputation']), + `Opening supply and formation destinations must leave passive categories independent: ${JSON.stringify(destinationCategories)}` + ); + + const fullyAcknowledged = acknowledgeCampaignVictoryReward(scenario.id, ['gold', 'reputation']); + const persistedFullState = JSON.parse(values.get(campaignStorageKey)); + assert( + fullyAcknowledged.acknowledgedVictoryRewardBattleIds.includes(scenario.id) && + JSON.stringify(fullyAcknowledged.acknowledgedVictoryRewardCategories[scenario.id]) === JSON.stringify(expectedCategories) && + getPendingCampaignVictoryRewardCategories(scenario.id).length === 0 && + getPendingCampaignVictoryRewardReport() === undefined && + persistedFullState.acknowledgedVictoryRewardBattleIds.includes(scenario.id), + `The legacy battle-level flag must be written only after every actual category is acknowledged: ${JSON.stringify(fullyAcknowledged)}` + ); + + const legacySave = structuredClone(persistedFullState); + delete legacySave.acknowledgedVictoryRewardCategories; + values.set(campaignStorageKey, JSON.stringify(legacySave)); + const migratedLegacySave = loadCampaignState(); + assert( + migratedLegacySave.acknowledgedVictoryRewardBattleIds.includes(scenario.id) && + JSON.stringify(migratedLegacySave.acknowledgedVictoryRewardCategories[scenario.id]) === JSON.stringify(expectedCategories) && + getPendingCampaignVictoryRewardReport() === undefined, + `A legacy all-or-nothing acknowledgement must migrate to every actual reward category: ${JSON.stringify(migratedLegacySave)}` + ); + + const partialSave = structuredClone(persistedFullState); + partialSave.acknowledgedVictoryRewardBattleIds = []; + partialSave.acknowledgedVictoryRewardCategories = { + [scenario.id]: ['equipment', 'unknown-category'], + 'second-battle-yellow-turban-pursuit': ['equipment'] + }; + values.set(campaignStorageKey, JSON.stringify(partialSave)); + const normalizedPartialSave = loadCampaignState(); + assert( + JSON.stringify(normalizedPartialSave.acknowledgedVictoryRewardCategories) === JSON.stringify({ [scenario.id]: ['equipment'] }) && + !normalizedPartialSave.acknowledgedVictoryRewardBattleIds.includes(scenario.id) && + getPendingCampaignVictoryRewardCategories(scenario.id).includes('supplies'), + `Normalization must retain valid partial progress while dropping unknown categories and non-victory battles: ${JSON.stringify(normalizedPartialSave.acknowledgedVictoryRewardCategories)}` + ); + + const repeatedAll = acknowledgeCampaignVictoryReward(scenario.id); + assert( + repeatedAll.acknowledgedVictoryRewardBattleIds.includes(scenario.id) && + JSON.stringify(repeatedAll.acknowledgedVictoryRewardCategories[scenario.id]) === JSON.stringify(expectedCategories), + 'The legacy one-argument acknowledgement API must remain an idempotent acknowledge-all operation.' + ); + + const current = getCampaignState(); + assert(current.acknowledgedVictoryRewardBattleIds.includes(scenario.id), 'Acknowledgement state must remain available through cloned campaign reads.'); + + resetCampaignState(); + setFirstBattleReport(report); + acknowledgeCampaignVictoryReward(scenario.id, ['gold', 'reputation', 'supplies', 'recruits', 'unlocks']); + const secondScenario = battleScenarios['second-battle-yellow-turban-pursuit']; + const secondReport = { + ...report, + battleId: secondScenario.id, + battleTitle: secondScenario.title, + units: secondScenario.units.filter((unit) => unit.faction === 'ally'), + bonds: secondScenario.bonds.map((bond) => ({ ...bond, battleExp: 0 })), + itemRewards: ['Bean x1'], + campaignRewards: { + supplies: ['Bean x1'], + equipment: [], + reputation: [], + recruits: [], + unlocks: [] + }, + createdAt: '2026-07-19T03:00:00.000Z' + }; + setFirstBattleReport(secondReport); + assert( + getPendingCampaignVictoryRewardReport()?.battleId === secondScenario.id && + JSON.stringify(getPendingCampaignVictoryRewardBattleIds()) === JSON.stringify([scenario.id, secondScenario.id]) && + getPendingCampaignVictoryRewardCategories().includes('equipment') && + getPendingCampaignVictoryRewardCategories().includes('supplies'), + 'A newer victory must not hide unreviewed reward categories from an earlier settlement.' + ); + acknowledgeCampaignVictoryReward(secondScenario.id); + assert( + getPendingCampaignVictoryRewardReport()?.battleId === scenario.id && + JSON.stringify(getPendingCampaignVictoryRewardCategories()) === JSON.stringify(['equipment']), + 'After reviewing the latest victory, the next pending reward report and aggregated NEW category must resurface.' + ); + + console.log('Verified category-level victory reward acknowledgements, cross-victory pending visibility, destination isolation, persistence, and legacy migration.'); +} finally { + await server.close(); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} diff --git a/src/assets/images/units/unit-cao-cao-actions.webp b/src/assets/images/units/unit-cao-cao-actions.webp new file mode 100644 index 0000000..4898f6f Binary files /dev/null and b/src/assets/images/units/unit-cao-cao-actions.webp differ diff --git a/src/assets/images/units/unit-cao-cao.webp b/src/assets/images/units/unit-cao-cao.webp new file mode 100644 index 0000000..46a035c Binary files /dev/null and b/src/assets/images/units/unit-cao-cao.webp differ diff --git a/src/assets/images/units/unit-fa-zheng-actions.webp b/src/assets/images/units/unit-fa-zheng-actions.webp new file mode 100644 index 0000000..0836091 Binary files /dev/null and b/src/assets/images/units/unit-fa-zheng-actions.webp differ diff --git a/src/assets/images/units/unit-fa-zheng.webp b/src/assets/images/units/unit-fa-zheng.webp new file mode 100644 index 0000000..acaaad7 Binary files /dev/null and b/src/assets/images/units/unit-fa-zheng.webp differ diff --git a/src/assets/images/units/unit-gong-zhi-actions.webp b/src/assets/images/units/unit-gong-zhi-actions.webp new file mode 100644 index 0000000..d652f71 Binary files /dev/null and b/src/assets/images/units/unit-gong-zhi-actions.webp differ diff --git a/src/assets/images/units/unit-gong-zhi.webp b/src/assets/images/units/unit-gong-zhi.webp new file mode 100644 index 0000000..d304ba4 Binary files /dev/null and b/src/assets/images/units/unit-gong-zhi.webp differ diff --git a/src/assets/images/units/unit-guan-yu-actions.webp b/src/assets/images/units/unit-guan-yu-actions.webp new file mode 100644 index 0000000..5096571 Binary files /dev/null and b/src/assets/images/units/unit-guan-yu-actions.webp differ diff --git a/src/assets/images/units/unit-guan-yu.webp b/src/assets/images/units/unit-guan-yu.webp new file mode 100644 index 0000000..7a2cb3a Binary files /dev/null and b/src/assets/images/units/unit-guan-yu.webp differ diff --git a/src/assets/images/units/unit-huang-quan-actions.webp b/src/assets/images/units/unit-huang-quan-actions.webp new file mode 100644 index 0000000..05f82e8 Binary files /dev/null and b/src/assets/images/units/unit-huang-quan-actions.webp differ diff --git a/src/assets/images/units/unit-huang-quan.webp b/src/assets/images/units/unit-huang-quan.webp new file mode 100644 index 0000000..20b3c7f Binary files /dev/null and b/src/assets/images/units/unit-huang-quan.webp differ diff --git a/src/assets/images/units/unit-huang-zhong-actions.webp b/src/assets/images/units/unit-huang-zhong-actions.webp new file mode 100644 index 0000000..a2ee4b2 Binary files /dev/null and b/src/assets/images/units/unit-huang-zhong-actions.webp differ diff --git a/src/assets/images/units/unit-huang-zhong.webp b/src/assets/images/units/unit-huang-zhong.webp new file mode 100644 index 0000000..e562716 Binary files /dev/null and b/src/assets/images/units/unit-huang-zhong.webp differ diff --git a/src/assets/images/units/unit-jiang-wei-actions.webp b/src/assets/images/units/unit-jiang-wei-actions.webp new file mode 100644 index 0000000..b8ad30f Binary files /dev/null and b/src/assets/images/units/unit-jiang-wei-actions.webp differ diff --git a/src/assets/images/units/unit-jiang-wei.webp b/src/assets/images/units/unit-jiang-wei.webp new file mode 100644 index 0000000..ce2a56f Binary files /dev/null and b/src/assets/images/units/unit-jiang-wei.webp differ diff --git a/src/assets/images/units/unit-li-yan-actions.webp b/src/assets/images/units/unit-li-yan-actions.webp new file mode 100644 index 0000000..0ff0a87 Binary files /dev/null and b/src/assets/images/units/unit-li-yan-actions.webp differ diff --git a/src/assets/images/units/unit-li-yan.webp b/src/assets/images/units/unit-li-yan.webp new file mode 100644 index 0000000..8db8555 Binary files /dev/null and b/src/assets/images/units/unit-li-yan.webp differ diff --git a/src/assets/images/units/unit-liu-bei-actions.webp b/src/assets/images/units/unit-liu-bei-actions.webp new file mode 100644 index 0000000..6b89fdf Binary files /dev/null and b/src/assets/images/units/unit-liu-bei-actions.webp differ diff --git a/src/assets/images/units/unit-liu-bei.webp b/src/assets/images/units/unit-liu-bei.webp new file mode 100644 index 0000000..f37e9d7 Binary files /dev/null and b/src/assets/images/units/unit-liu-bei.webp differ diff --git a/src/assets/images/units/unit-lu-bu-actions.webp b/src/assets/images/units/unit-lu-bu-actions.webp new file mode 100644 index 0000000..e4e28f8 Binary files /dev/null and b/src/assets/images/units/unit-lu-bu-actions.webp differ diff --git a/src/assets/images/units/unit-lu-bu.webp b/src/assets/images/units/unit-lu-bu.webp new file mode 100644 index 0000000..069a015 Binary files /dev/null and b/src/assets/images/units/unit-lu-bu.webp differ diff --git a/src/assets/images/units/unit-lu-meng-actions.webp b/src/assets/images/units/unit-lu-meng-actions.webp new file mode 100644 index 0000000..d1cfa0b Binary files /dev/null and b/src/assets/images/units/unit-lu-meng-actions.webp differ diff --git a/src/assets/images/units/unit-lu-meng.webp b/src/assets/images/units/unit-lu-meng.webp new file mode 100644 index 0000000..fa31de5 Binary files /dev/null and b/src/assets/images/units/unit-lu-meng.webp differ diff --git a/src/assets/images/units/unit-ma-chao-actions.webp b/src/assets/images/units/unit-ma-chao-actions.webp new file mode 100644 index 0000000..0faf713 Binary files /dev/null and b/src/assets/images/units/unit-ma-chao-actions.webp differ diff --git a/src/assets/images/units/unit-ma-chao.webp b/src/assets/images/units/unit-ma-chao.webp new file mode 100644 index 0000000..e0cbc8c Binary files /dev/null and b/src/assets/images/units/unit-ma-chao.webp differ diff --git a/src/assets/images/units/unit-ma-dai-actions.webp b/src/assets/images/units/unit-ma-dai-actions.webp new file mode 100644 index 0000000..2f74d15 Binary files /dev/null and b/src/assets/images/units/unit-ma-dai-actions.webp differ diff --git a/src/assets/images/units/unit-ma-dai.webp b/src/assets/images/units/unit-ma-dai.webp new file mode 100644 index 0000000..2d165c2 Binary files /dev/null and b/src/assets/images/units/unit-ma-dai.webp differ diff --git a/src/assets/images/units/unit-ma-liang-actions.webp b/src/assets/images/units/unit-ma-liang-actions.webp new file mode 100644 index 0000000..f9ab740 Binary files /dev/null and b/src/assets/images/units/unit-ma-liang-actions.webp differ diff --git a/src/assets/images/units/unit-ma-liang.webp b/src/assets/images/units/unit-ma-liang.webp new file mode 100644 index 0000000..e7ef63f Binary files /dev/null and b/src/assets/images/units/unit-ma-liang.webp differ diff --git a/src/assets/images/units/unit-meng-huo-actions.webp b/src/assets/images/units/unit-meng-huo-actions.webp new file mode 100644 index 0000000..6811a90 Binary files /dev/null and b/src/assets/images/units/unit-meng-huo-actions.webp differ diff --git a/src/assets/images/units/unit-meng-huo.webp b/src/assets/images/units/unit-meng-huo.webp new file mode 100644 index 0000000..4972adf Binary files /dev/null and b/src/assets/images/units/unit-meng-huo.webp differ diff --git a/src/assets/images/units/unit-nanman-infantry-actions.webp b/src/assets/images/units/unit-nanman-infantry-actions.webp new file mode 100644 index 0000000..1931963 Binary files /dev/null and b/src/assets/images/units/unit-nanman-infantry-actions.webp differ diff --git a/src/assets/images/units/unit-nanman-infantry-jungle-actions.webp b/src/assets/images/units/unit-nanman-infantry-jungle-actions.webp new file mode 100644 index 0000000..09ae43d Binary files /dev/null and b/src/assets/images/units/unit-nanman-infantry-jungle-actions.webp differ diff --git a/src/assets/images/units/unit-nanman-infantry-jungle.webp b/src/assets/images/units/unit-nanman-infantry-jungle.webp new file mode 100644 index 0000000..69ed24c Binary files /dev/null and b/src/assets/images/units/unit-nanman-infantry-jungle.webp differ diff --git a/src/assets/images/units/unit-nanman-infantry-spear-actions.webp b/src/assets/images/units/unit-nanman-infantry-spear-actions.webp new file mode 100644 index 0000000..858bf53 Binary files /dev/null and b/src/assets/images/units/unit-nanman-infantry-spear-actions.webp differ diff --git a/src/assets/images/units/unit-nanman-infantry-spear.webp b/src/assets/images/units/unit-nanman-infantry-spear.webp new file mode 100644 index 0000000..23c6746 Binary files /dev/null and b/src/assets/images/units/unit-nanman-infantry-spear.webp differ diff --git a/src/assets/images/units/unit-nanman-infantry.webp b/src/assets/images/units/unit-nanman-infantry.webp new file mode 100644 index 0000000..a532e3d Binary files /dev/null and b/src/assets/images/units/unit-nanman-infantry.webp differ diff --git a/src/assets/images/units/unit-nanman-officer-actions.webp b/src/assets/images/units/unit-nanman-officer-actions.webp new file mode 100644 index 0000000..d72a3bd Binary files /dev/null and b/src/assets/images/units/unit-nanman-officer-actions.webp differ diff --git a/src/assets/images/units/unit-nanman-officer-bronze-actions.webp b/src/assets/images/units/unit-nanman-officer-bronze-actions.webp new file mode 100644 index 0000000..866dfab Binary files /dev/null and b/src/assets/images/units/unit-nanman-officer-bronze-actions.webp differ diff --git a/src/assets/images/units/unit-nanman-officer-bronze.webp b/src/assets/images/units/unit-nanman-officer-bronze.webp new file mode 100644 index 0000000..70c9b9a Binary files /dev/null and b/src/assets/images/units/unit-nanman-officer-bronze.webp differ diff --git a/src/assets/images/units/unit-nanman-officer-warlord-actions.webp b/src/assets/images/units/unit-nanman-officer-warlord-actions.webp new file mode 100644 index 0000000..1f20998 Binary files /dev/null and b/src/assets/images/units/unit-nanman-officer-warlord-actions.webp differ diff --git a/src/assets/images/units/unit-nanman-officer-warlord.webp b/src/assets/images/units/unit-nanman-officer-warlord.webp new file mode 100644 index 0000000..940b00a Binary files /dev/null and b/src/assets/images/units/unit-nanman-officer-warlord.webp differ diff --git a/src/assets/images/units/unit-nanman-officer.webp b/src/assets/images/units/unit-nanman-officer.webp new file mode 100644 index 0000000..6d242be Binary files /dev/null and b/src/assets/images/units/unit-nanman-officer.webp differ diff --git a/src/assets/images/units/unit-nanman-shaman-actions.webp b/src/assets/images/units/unit-nanman-shaman-actions.webp new file mode 100644 index 0000000..fe5841e Binary files /dev/null and b/src/assets/images/units/unit-nanman-shaman-actions.webp differ diff --git a/src/assets/images/units/unit-nanman-shaman-ember-actions.webp b/src/assets/images/units/unit-nanman-shaman-ember-actions.webp new file mode 100644 index 0000000..fffca49 Binary files /dev/null and b/src/assets/images/units/unit-nanman-shaman-ember-actions.webp differ diff --git a/src/assets/images/units/unit-nanman-shaman-ember.webp b/src/assets/images/units/unit-nanman-shaman-ember.webp new file mode 100644 index 0000000..f99a01b Binary files /dev/null and b/src/assets/images/units/unit-nanman-shaman-ember.webp differ diff --git a/src/assets/images/units/unit-nanman-shaman-storm-actions.webp b/src/assets/images/units/unit-nanman-shaman-storm-actions.webp new file mode 100644 index 0000000..10f52c7 Binary files /dev/null and b/src/assets/images/units/unit-nanman-shaman-storm-actions.webp differ diff --git a/src/assets/images/units/unit-nanman-shaman-storm.webp b/src/assets/images/units/unit-nanman-shaman-storm.webp new file mode 100644 index 0000000..2922861 Binary files /dev/null and b/src/assets/images/units/unit-nanman-shaman-storm.webp differ diff --git a/src/assets/images/units/unit-nanman-shaman.webp b/src/assets/images/units/unit-nanman-shaman.webp new file mode 100644 index 0000000..1d3e88d Binary files /dev/null and b/src/assets/images/units/unit-nanman-shaman.webp differ diff --git a/src/assets/images/units/unit-pang-tong-actions.webp b/src/assets/images/units/unit-pang-tong-actions.webp new file mode 100644 index 0000000..4db77ad Binary files /dev/null and b/src/assets/images/units/unit-pang-tong-actions.webp differ diff --git a/src/assets/images/units/unit-pang-tong.webp b/src/assets/images/units/unit-pang-tong.webp new file mode 100644 index 0000000..dc36a70 Binary files /dev/null and b/src/assets/images/units/unit-pang-tong.webp differ diff --git a/src/assets/images/units/unit-rebel-actions.webp b/src/assets/images/units/unit-rebel-actions.webp new file mode 100644 index 0000000..d65fab9 Binary files /dev/null and b/src/assets/images/units/unit-rebel-actions.webp differ diff --git a/src/assets/images/units/unit-rebel-archer-actions.webp b/src/assets/images/units/unit-rebel-archer-actions.webp new file mode 100644 index 0000000..ddf8ba1 Binary files /dev/null and b/src/assets/images/units/unit-rebel-archer-actions.webp differ diff --git a/src/assets/images/units/unit-rebel-archer-fire-actions.webp b/src/assets/images/units/unit-rebel-archer-fire-actions.webp new file mode 100644 index 0000000..7121589 Binary files /dev/null and b/src/assets/images/units/unit-rebel-archer-fire-actions.webp differ diff --git a/src/assets/images/units/unit-rebel-archer-fire.webp b/src/assets/images/units/unit-rebel-archer-fire.webp new file mode 100644 index 0000000..e002e8f Binary files /dev/null and b/src/assets/images/units/unit-rebel-archer-fire.webp differ diff --git a/src/assets/images/units/unit-rebel-archer-veteran-actions.webp b/src/assets/images/units/unit-rebel-archer-veteran-actions.webp new file mode 100644 index 0000000..2d5427f Binary files /dev/null and b/src/assets/images/units/unit-rebel-archer-veteran-actions.webp differ diff --git a/src/assets/images/units/unit-rebel-archer-veteran.webp b/src/assets/images/units/unit-rebel-archer-veteran.webp new file mode 100644 index 0000000..d122cbb Binary files /dev/null and b/src/assets/images/units/unit-rebel-archer-veteran.webp differ diff --git a/src/assets/images/units/unit-rebel-archer.webp b/src/assets/images/units/unit-rebel-archer.webp new file mode 100644 index 0000000..e9c848b Binary files /dev/null and b/src/assets/images/units/unit-rebel-archer.webp differ diff --git a/src/assets/images/units/unit-rebel-cavalry-actions.webp b/src/assets/images/units/unit-rebel-cavalry-actions.webp new file mode 100644 index 0000000..3fd0633 Binary files /dev/null and b/src/assets/images/units/unit-rebel-cavalry-actions.webp differ diff --git a/src/assets/images/units/unit-rebel-cavalry-veteran-actions.webp b/src/assets/images/units/unit-rebel-cavalry-veteran-actions.webp new file mode 100644 index 0000000..26ad5e8 Binary files /dev/null and b/src/assets/images/units/unit-rebel-cavalry-veteran-actions.webp differ diff --git a/src/assets/images/units/unit-rebel-cavalry-veteran.webp b/src/assets/images/units/unit-rebel-cavalry-veteran.webp new file mode 100644 index 0000000..3f3d437 Binary files /dev/null and b/src/assets/images/units/unit-rebel-cavalry-veteran.webp differ diff --git a/src/assets/images/units/unit-rebel-cavalry.webp b/src/assets/images/units/unit-rebel-cavalry.webp new file mode 100644 index 0000000..934fc7a Binary files /dev/null and b/src/assets/images/units/unit-rebel-cavalry.webp differ diff --git a/src/assets/images/units/unit-rebel-leader-actions.webp b/src/assets/images/units/unit-rebel-leader-actions.webp new file mode 100644 index 0000000..ed5e1f0 Binary files /dev/null and b/src/assets/images/units/unit-rebel-leader-actions.webp differ diff --git a/src/assets/images/units/unit-rebel-leader-warlord-actions.webp b/src/assets/images/units/unit-rebel-leader-warlord-actions.webp new file mode 100644 index 0000000..27c4450 Binary files /dev/null and b/src/assets/images/units/unit-rebel-leader-warlord-actions.webp differ diff --git a/src/assets/images/units/unit-rebel-leader-warlord.webp b/src/assets/images/units/unit-rebel-leader-warlord.webp new file mode 100644 index 0000000..e39f627 Binary files /dev/null and b/src/assets/images/units/unit-rebel-leader-warlord.webp differ diff --git a/src/assets/images/units/unit-rebel-leader.webp b/src/assets/images/units/unit-rebel-leader.webp new file mode 100644 index 0000000..24bc75d Binary files /dev/null and b/src/assets/images/units/unit-rebel-leader.webp differ diff --git a/src/assets/images/units/unit-rebel-ragged-actions.webp b/src/assets/images/units/unit-rebel-ragged-actions.webp new file mode 100644 index 0000000..f63d1f5 Binary files /dev/null and b/src/assets/images/units/unit-rebel-ragged-actions.webp differ diff --git a/src/assets/images/units/unit-rebel-ragged.webp b/src/assets/images/units/unit-rebel-ragged.webp new file mode 100644 index 0000000..f45908e Binary files /dev/null and b/src/assets/images/units/unit-rebel-ragged.webp differ diff --git a/src/assets/images/units/unit-rebel.webp b/src/assets/images/units/unit-rebel.webp new file mode 100644 index 0000000..3065d98 Binary files /dev/null and b/src/assets/images/units/unit-rebel.webp differ diff --git a/src/assets/images/units/unit-shu-archer-actions.webp b/src/assets/images/units/unit-shu-archer-actions.webp new file mode 100644 index 0000000..acc5bc7 Binary files /dev/null and b/src/assets/images/units/unit-shu-archer-actions.webp differ diff --git a/src/assets/images/units/unit-shu-archer-longbow-actions.webp b/src/assets/images/units/unit-shu-archer-longbow-actions.webp new file mode 100644 index 0000000..c06b80c Binary files /dev/null and b/src/assets/images/units/unit-shu-archer-longbow-actions.webp differ diff --git a/src/assets/images/units/unit-shu-archer-longbow.webp b/src/assets/images/units/unit-shu-archer-longbow.webp new file mode 100644 index 0000000..0cdfd5a Binary files /dev/null and b/src/assets/images/units/unit-shu-archer-longbow.webp differ diff --git a/src/assets/images/units/unit-shu-archer-veteran-actions.webp b/src/assets/images/units/unit-shu-archer-veteran-actions.webp new file mode 100644 index 0000000..fceae75 Binary files /dev/null and b/src/assets/images/units/unit-shu-archer-veteran-actions.webp differ diff --git a/src/assets/images/units/unit-shu-archer-veteran.webp b/src/assets/images/units/unit-shu-archer-veteran.webp new file mode 100644 index 0000000..1a3c7c1 Binary files /dev/null and b/src/assets/images/units/unit-shu-archer-veteran.webp differ diff --git a/src/assets/images/units/unit-shu-archer.webp b/src/assets/images/units/unit-shu-archer.webp new file mode 100644 index 0000000..7a8e02c Binary files /dev/null and b/src/assets/images/units/unit-shu-archer.webp differ diff --git a/src/assets/images/units/unit-shu-cavalry-actions.webp b/src/assets/images/units/unit-shu-cavalry-actions.webp new file mode 100644 index 0000000..d1ec3de Binary files /dev/null and b/src/assets/images/units/unit-shu-cavalry-actions.webp differ diff --git a/src/assets/images/units/unit-shu-cavalry-scout-actions.webp b/src/assets/images/units/unit-shu-cavalry-scout-actions.webp new file mode 100644 index 0000000..41a8298 Binary files /dev/null and b/src/assets/images/units/unit-shu-cavalry-scout-actions.webp differ diff --git a/src/assets/images/units/unit-shu-cavalry-scout.webp b/src/assets/images/units/unit-shu-cavalry-scout.webp new file mode 100644 index 0000000..5a84f89 Binary files /dev/null and b/src/assets/images/units/unit-shu-cavalry-scout.webp differ diff --git a/src/assets/images/units/unit-shu-cavalry-white-actions.webp b/src/assets/images/units/unit-shu-cavalry-white-actions.webp new file mode 100644 index 0000000..7eb3936 Binary files /dev/null and b/src/assets/images/units/unit-shu-cavalry-white-actions.webp differ diff --git a/src/assets/images/units/unit-shu-cavalry-white.webp b/src/assets/images/units/unit-shu-cavalry-white.webp new file mode 100644 index 0000000..2c6bce7 Binary files /dev/null and b/src/assets/images/units/unit-shu-cavalry-white.webp differ diff --git a/src/assets/images/units/unit-shu-cavalry.webp b/src/assets/images/units/unit-shu-cavalry.webp new file mode 100644 index 0000000..282b16d Binary files /dev/null and b/src/assets/images/units/unit-shu-cavalry.webp differ diff --git a/src/assets/images/units/unit-shu-infantry-actions.webp b/src/assets/images/units/unit-shu-infantry-actions.webp new file mode 100644 index 0000000..2ae38d3 Binary files /dev/null and b/src/assets/images/units/unit-shu-infantry-actions.webp differ diff --git a/src/assets/images/units/unit-shu-infantry-banner-actions.webp b/src/assets/images/units/unit-shu-infantry-banner-actions.webp new file mode 100644 index 0000000..df76def Binary files /dev/null and b/src/assets/images/units/unit-shu-infantry-banner-actions.webp differ diff --git a/src/assets/images/units/unit-shu-infantry-banner.webp b/src/assets/images/units/unit-shu-infantry-banner.webp new file mode 100644 index 0000000..2295fef Binary files /dev/null and b/src/assets/images/units/unit-shu-infantry-banner.webp differ diff --git a/src/assets/images/units/unit-shu-infantry-guard-actions.webp b/src/assets/images/units/unit-shu-infantry-guard-actions.webp new file mode 100644 index 0000000..90d246c Binary files /dev/null and b/src/assets/images/units/unit-shu-infantry-guard-actions.webp differ diff --git a/src/assets/images/units/unit-shu-infantry-guard.webp b/src/assets/images/units/unit-shu-infantry-guard.webp new file mode 100644 index 0000000..a57dcf6 Binary files /dev/null and b/src/assets/images/units/unit-shu-infantry-guard.webp differ diff --git a/src/assets/images/units/unit-shu-infantry.webp b/src/assets/images/units/unit-shu-infantry.webp new file mode 100644 index 0000000..5ac3506 Binary files /dev/null and b/src/assets/images/units/unit-shu-infantry.webp differ diff --git a/src/assets/images/units/unit-shu-officer-actions.webp b/src/assets/images/units/unit-shu-officer-actions.webp new file mode 100644 index 0000000..af2c176 Binary files /dev/null and b/src/assets/images/units/unit-shu-officer-actions.webp differ diff --git a/src/assets/images/units/unit-shu-officer-council-actions.webp b/src/assets/images/units/unit-shu-officer-council-actions.webp new file mode 100644 index 0000000..c47b8bd Binary files /dev/null and b/src/assets/images/units/unit-shu-officer-council-actions.webp differ diff --git a/src/assets/images/units/unit-shu-officer-council.webp b/src/assets/images/units/unit-shu-officer-council.webp new file mode 100644 index 0000000..6fd0cd8 Binary files /dev/null and b/src/assets/images/units/unit-shu-officer-council.webp differ diff --git a/src/assets/images/units/unit-shu-officer-vanguard-actions.webp b/src/assets/images/units/unit-shu-officer-vanguard-actions.webp new file mode 100644 index 0000000..d165eef Binary files /dev/null and b/src/assets/images/units/unit-shu-officer-vanguard-actions.webp differ diff --git a/src/assets/images/units/unit-shu-officer-vanguard.webp b/src/assets/images/units/unit-shu-officer-vanguard.webp new file mode 100644 index 0000000..9261559 Binary files /dev/null and b/src/assets/images/units/unit-shu-officer-vanguard.webp differ diff --git a/src/assets/images/units/unit-shu-officer.webp b/src/assets/images/units/unit-shu-officer.webp new file mode 100644 index 0000000..197f283 Binary files /dev/null and b/src/assets/images/units/unit-shu-officer.webp differ diff --git a/src/assets/images/units/unit-shu-spearman-actions.webp b/src/assets/images/units/unit-shu-spearman-actions.webp new file mode 100644 index 0000000..558d3cf Binary files /dev/null and b/src/assets/images/units/unit-shu-spearman-actions.webp differ diff --git a/src/assets/images/units/unit-shu-spearman-guard-actions.webp b/src/assets/images/units/unit-shu-spearman-guard-actions.webp new file mode 100644 index 0000000..235f9b0 Binary files /dev/null and b/src/assets/images/units/unit-shu-spearman-guard-actions.webp differ diff --git a/src/assets/images/units/unit-shu-spearman-guard.webp b/src/assets/images/units/unit-shu-spearman-guard.webp new file mode 100644 index 0000000..9a7b3c7 Binary files /dev/null and b/src/assets/images/units/unit-shu-spearman-guard.webp differ diff --git a/src/assets/images/units/unit-shu-spearman-veteran-actions.webp b/src/assets/images/units/unit-shu-spearman-veteran-actions.webp new file mode 100644 index 0000000..6fb206c Binary files /dev/null and b/src/assets/images/units/unit-shu-spearman-veteran-actions.webp differ diff --git a/src/assets/images/units/unit-shu-spearman-veteran.webp b/src/assets/images/units/unit-shu-spearman-veteran.webp new file mode 100644 index 0000000..a9a26ee Binary files /dev/null and b/src/assets/images/units/unit-shu-spearman-veteran.webp differ diff --git a/src/assets/images/units/unit-shu-spearman.webp b/src/assets/images/units/unit-shu-spearman.webp new file mode 100644 index 0000000..cfa90e3 Binary files /dev/null and b/src/assets/images/units/unit-shu-spearman.webp differ diff --git a/src/assets/images/units/unit-shu-strategist-actions.webp b/src/assets/images/units/unit-shu-strategist-actions.webp new file mode 100644 index 0000000..ebe5a9c Binary files /dev/null and b/src/assets/images/units/unit-shu-strategist-actions.webp differ diff --git a/src/assets/images/units/unit-shu-strategist-field-actions.webp b/src/assets/images/units/unit-shu-strategist-field-actions.webp new file mode 100644 index 0000000..61e7243 Binary files /dev/null and b/src/assets/images/units/unit-shu-strategist-field-actions.webp differ diff --git a/src/assets/images/units/unit-shu-strategist-field.webp b/src/assets/images/units/unit-shu-strategist-field.webp new file mode 100644 index 0000000..4690e14 Binary files /dev/null and b/src/assets/images/units/unit-shu-strategist-field.webp differ diff --git a/src/assets/images/units/unit-shu-strategist-white-actions.webp b/src/assets/images/units/unit-shu-strategist-white-actions.webp new file mode 100644 index 0000000..87da503 Binary files /dev/null and b/src/assets/images/units/unit-shu-strategist-white-actions.webp differ diff --git a/src/assets/images/units/unit-shu-strategist-white.webp b/src/assets/images/units/unit-shu-strategist-white.webp new file mode 100644 index 0000000..3eadf35 Binary files /dev/null and b/src/assets/images/units/unit-shu-strategist-white.webp differ diff --git a/src/assets/images/units/unit-shu-strategist.webp b/src/assets/images/units/unit-shu-strategist.webp new file mode 100644 index 0000000..5bb5e7c Binary files /dev/null and b/src/assets/images/units/unit-shu-strategist.webp differ diff --git a/src/assets/images/units/unit-sima-yi-actions.webp b/src/assets/images/units/unit-sima-yi-actions.webp new file mode 100644 index 0000000..daa71cf Binary files /dev/null and b/src/assets/images/units/unit-sima-yi-actions.webp differ diff --git a/src/assets/images/units/unit-sima-yi.webp b/src/assets/images/units/unit-sima-yi.webp new file mode 100644 index 0000000..67ea3ae Binary files /dev/null and b/src/assets/images/units/unit-sima-yi.webp differ diff --git a/src/assets/images/units/unit-wang-ping-actions.webp b/src/assets/images/units/unit-wang-ping-actions.webp new file mode 100644 index 0000000..9068b87 Binary files /dev/null and b/src/assets/images/units/unit-wang-ping-actions.webp differ diff --git a/src/assets/images/units/unit-wang-ping.webp b/src/assets/images/units/unit-wang-ping.webp new file mode 100644 index 0000000..37207c6 Binary files /dev/null and b/src/assets/images/units/unit-wang-ping.webp differ diff --git a/src/assets/images/units/unit-wei-archer-actions.webp b/src/assets/images/units/unit-wei-archer-actions.webp new file mode 100644 index 0000000..9c92531 Binary files /dev/null and b/src/assets/images/units/unit-wei-archer-actions.webp differ diff --git a/src/assets/images/units/unit-wei-archer-crossbow-actions.webp b/src/assets/images/units/unit-wei-archer-crossbow-actions.webp new file mode 100644 index 0000000..6b1780c Binary files /dev/null and b/src/assets/images/units/unit-wei-archer-crossbow-actions.webp differ diff --git a/src/assets/images/units/unit-wei-archer-crossbow.webp b/src/assets/images/units/unit-wei-archer-crossbow.webp new file mode 100644 index 0000000..601d29f Binary files /dev/null and b/src/assets/images/units/unit-wei-archer-crossbow.webp differ diff --git a/src/assets/images/units/unit-wei-archer-veteran-actions.webp b/src/assets/images/units/unit-wei-archer-veteran-actions.webp new file mode 100644 index 0000000..9f26b02 Binary files /dev/null and b/src/assets/images/units/unit-wei-archer-veteran-actions.webp differ diff --git a/src/assets/images/units/unit-wei-archer-veteran.webp b/src/assets/images/units/unit-wei-archer-veteran.webp new file mode 100644 index 0000000..91730a0 Binary files /dev/null and b/src/assets/images/units/unit-wei-archer-veteran.webp differ diff --git a/src/assets/images/units/unit-wei-archer.webp b/src/assets/images/units/unit-wei-archer.webp new file mode 100644 index 0000000..2f48996 Binary files /dev/null and b/src/assets/images/units/unit-wei-archer.webp differ diff --git a/src/assets/images/units/unit-wei-cavalry-actions.webp b/src/assets/images/units/unit-wei-cavalry-actions.webp new file mode 100644 index 0000000..a073607 Binary files /dev/null and b/src/assets/images/units/unit-wei-cavalry-actions.webp differ diff --git a/src/assets/images/units/unit-wei-cavalry-elite-actions.webp b/src/assets/images/units/unit-wei-cavalry-elite-actions.webp new file mode 100644 index 0000000..934c404 Binary files /dev/null and b/src/assets/images/units/unit-wei-cavalry-elite-actions.webp differ diff --git a/src/assets/images/units/unit-wei-cavalry-elite.webp b/src/assets/images/units/unit-wei-cavalry-elite.webp new file mode 100644 index 0000000..0763f19 Binary files /dev/null and b/src/assets/images/units/unit-wei-cavalry-elite.webp differ diff --git a/src/assets/images/units/unit-wei-cavalry-iron-actions.webp b/src/assets/images/units/unit-wei-cavalry-iron-actions.webp new file mode 100644 index 0000000..0b81003 Binary files /dev/null and b/src/assets/images/units/unit-wei-cavalry-iron-actions.webp differ diff --git a/src/assets/images/units/unit-wei-cavalry-iron.webp b/src/assets/images/units/unit-wei-cavalry-iron.webp new file mode 100644 index 0000000..45b0e04 Binary files /dev/null and b/src/assets/images/units/unit-wei-cavalry-iron.webp differ diff --git a/src/assets/images/units/unit-wei-cavalry.webp b/src/assets/images/units/unit-wei-cavalry.webp new file mode 100644 index 0000000..84e476e Binary files /dev/null and b/src/assets/images/units/unit-wei-cavalry.webp differ diff --git a/src/assets/images/units/unit-wei-infantry-actions.webp b/src/assets/images/units/unit-wei-infantry-actions.webp new file mode 100644 index 0000000..bef198a Binary files /dev/null and b/src/assets/images/units/unit-wei-infantry-actions.webp differ diff --git a/src/assets/images/units/unit-wei-infantry-shield-actions.webp b/src/assets/images/units/unit-wei-infantry-shield-actions.webp new file mode 100644 index 0000000..f30b487 Binary files /dev/null and b/src/assets/images/units/unit-wei-infantry-shield-actions.webp differ diff --git a/src/assets/images/units/unit-wei-infantry-shield.webp b/src/assets/images/units/unit-wei-infantry-shield.webp new file mode 100644 index 0000000..127c70f Binary files /dev/null and b/src/assets/images/units/unit-wei-infantry-shield.webp differ diff --git a/src/assets/images/units/unit-wei-infantry-veteran-actions.webp b/src/assets/images/units/unit-wei-infantry-veteran-actions.webp new file mode 100644 index 0000000..bb9d1ac Binary files /dev/null and b/src/assets/images/units/unit-wei-infantry-veteran-actions.webp differ diff --git a/src/assets/images/units/unit-wei-infantry-veteran.webp b/src/assets/images/units/unit-wei-infantry-veteran.webp new file mode 100644 index 0000000..7ec12d5 Binary files /dev/null and b/src/assets/images/units/unit-wei-infantry-veteran.webp differ diff --git a/src/assets/images/units/unit-wei-infantry.webp b/src/assets/images/units/unit-wei-infantry.webp new file mode 100644 index 0000000..821cc44 Binary files /dev/null and b/src/assets/images/units/unit-wei-infantry.webp differ diff --git a/src/assets/images/units/unit-wei-officer-actions.webp b/src/assets/images/units/unit-wei-officer-actions.webp new file mode 100644 index 0000000..765f11d Binary files /dev/null and b/src/assets/images/units/unit-wei-officer-actions.webp differ diff --git a/src/assets/images/units/unit-wei-officer-gate-actions.webp b/src/assets/images/units/unit-wei-officer-gate-actions.webp new file mode 100644 index 0000000..12252f4 Binary files /dev/null and b/src/assets/images/units/unit-wei-officer-gate-actions.webp differ diff --git a/src/assets/images/units/unit-wei-officer-gate.webp b/src/assets/images/units/unit-wei-officer-gate.webp new file mode 100644 index 0000000..c797d43 Binary files /dev/null and b/src/assets/images/units/unit-wei-officer-gate.webp differ diff --git a/src/assets/images/units/unit-wei-officer-iron-actions.webp b/src/assets/images/units/unit-wei-officer-iron-actions.webp new file mode 100644 index 0000000..8570ef1 Binary files /dev/null and b/src/assets/images/units/unit-wei-officer-iron-actions.webp differ diff --git a/src/assets/images/units/unit-wei-officer-iron.webp b/src/assets/images/units/unit-wei-officer-iron.webp new file mode 100644 index 0000000..759d176 Binary files /dev/null and b/src/assets/images/units/unit-wei-officer-iron.webp differ diff --git a/src/assets/images/units/unit-wei-officer.webp b/src/assets/images/units/unit-wei-officer.webp new file mode 100644 index 0000000..8ab727a Binary files /dev/null and b/src/assets/images/units/unit-wei-officer.webp differ diff --git a/src/assets/images/units/unit-wei-strategist-actions.webp b/src/assets/images/units/unit-wei-strategist-actions.webp new file mode 100644 index 0000000..73fb01a Binary files /dev/null and b/src/assets/images/units/unit-wei-strategist-actions.webp differ diff --git a/src/assets/images/units/unit-wei-strategist-black-actions.webp b/src/assets/images/units/unit-wei-strategist-black-actions.webp new file mode 100644 index 0000000..41b24a5 Binary files /dev/null and b/src/assets/images/units/unit-wei-strategist-black-actions.webp differ diff --git a/src/assets/images/units/unit-wei-strategist-black.webp b/src/assets/images/units/unit-wei-strategist-black.webp new file mode 100644 index 0000000..b44b3de Binary files /dev/null and b/src/assets/images/units/unit-wei-strategist-black.webp differ diff --git a/src/assets/images/units/unit-wei-strategist-blue-actions.webp b/src/assets/images/units/unit-wei-strategist-blue-actions.webp new file mode 100644 index 0000000..65172ec Binary files /dev/null and b/src/assets/images/units/unit-wei-strategist-blue-actions.webp differ diff --git a/src/assets/images/units/unit-wei-strategist-blue.webp b/src/assets/images/units/unit-wei-strategist-blue.webp new file mode 100644 index 0000000..7b3b843 Binary files /dev/null and b/src/assets/images/units/unit-wei-strategist-blue.webp differ diff --git a/src/assets/images/units/unit-wei-strategist.webp b/src/assets/images/units/unit-wei-strategist.webp new file mode 100644 index 0000000..b04391e Binary files /dev/null and b/src/assets/images/units/unit-wei-strategist.webp differ diff --git a/src/assets/images/units/unit-wei-yan-actions.webp b/src/assets/images/units/unit-wei-yan-actions.webp new file mode 100644 index 0000000..6754575 Binary files /dev/null and b/src/assets/images/units/unit-wei-yan-actions.webp differ diff --git a/src/assets/images/units/unit-wei-yan.webp b/src/assets/images/units/unit-wei-yan.webp new file mode 100644 index 0000000..b15dd87 Binary files /dev/null and b/src/assets/images/units/unit-wei-yan.webp differ diff --git a/src/assets/images/units/unit-wu-archer-actions.webp b/src/assets/images/units/unit-wu-archer-actions.webp new file mode 100644 index 0000000..02039ea Binary files /dev/null and b/src/assets/images/units/unit-wu-archer-actions.webp differ diff --git a/src/assets/images/units/unit-wu-archer-naval-actions.webp b/src/assets/images/units/unit-wu-archer-naval-actions.webp new file mode 100644 index 0000000..1fffde9 Binary files /dev/null and b/src/assets/images/units/unit-wu-archer-naval-actions.webp differ diff --git a/src/assets/images/units/unit-wu-archer-naval.webp b/src/assets/images/units/unit-wu-archer-naval.webp new file mode 100644 index 0000000..8a96d21 Binary files /dev/null and b/src/assets/images/units/unit-wu-archer-naval.webp differ diff --git a/src/assets/images/units/unit-wu-archer-river-actions.webp b/src/assets/images/units/unit-wu-archer-river-actions.webp new file mode 100644 index 0000000..f13c044 Binary files /dev/null and b/src/assets/images/units/unit-wu-archer-river-actions.webp differ diff --git a/src/assets/images/units/unit-wu-archer-river.webp b/src/assets/images/units/unit-wu-archer-river.webp new file mode 100644 index 0000000..33cd524 Binary files /dev/null and b/src/assets/images/units/unit-wu-archer-river.webp differ diff --git a/src/assets/images/units/unit-wu-archer.webp b/src/assets/images/units/unit-wu-archer.webp new file mode 100644 index 0000000..10d8bea Binary files /dev/null and b/src/assets/images/units/unit-wu-archer.webp differ diff --git a/src/assets/images/units/unit-wu-cavalry-actions.webp b/src/assets/images/units/unit-wu-cavalry-actions.webp new file mode 100644 index 0000000..be9a2eb Binary files /dev/null and b/src/assets/images/units/unit-wu-cavalry-actions.webp differ diff --git a/src/assets/images/units/unit-wu-cavalry-elite-actions.webp b/src/assets/images/units/unit-wu-cavalry-elite-actions.webp new file mode 100644 index 0000000..be9f439 Binary files /dev/null and b/src/assets/images/units/unit-wu-cavalry-elite-actions.webp differ diff --git a/src/assets/images/units/unit-wu-cavalry-elite.webp b/src/assets/images/units/unit-wu-cavalry-elite.webp new file mode 100644 index 0000000..0215e19 Binary files /dev/null and b/src/assets/images/units/unit-wu-cavalry-elite.webp differ diff --git a/src/assets/images/units/unit-wu-cavalry-river-actions.webp b/src/assets/images/units/unit-wu-cavalry-river-actions.webp new file mode 100644 index 0000000..fabfda2 Binary files /dev/null and b/src/assets/images/units/unit-wu-cavalry-river-actions.webp differ diff --git a/src/assets/images/units/unit-wu-cavalry-river.webp b/src/assets/images/units/unit-wu-cavalry-river.webp new file mode 100644 index 0000000..a0568d6 Binary files /dev/null and b/src/assets/images/units/unit-wu-cavalry-river.webp differ diff --git a/src/assets/images/units/unit-wu-cavalry.webp b/src/assets/images/units/unit-wu-cavalry.webp new file mode 100644 index 0000000..2ac386c Binary files /dev/null and b/src/assets/images/units/unit-wu-cavalry.webp differ diff --git a/src/assets/images/units/unit-wu-infantry-actions.webp b/src/assets/images/units/unit-wu-infantry-actions.webp new file mode 100644 index 0000000..f8242de Binary files /dev/null and b/src/assets/images/units/unit-wu-infantry-actions.webp differ diff --git a/src/assets/images/units/unit-wu-infantry-marine-actions.webp b/src/assets/images/units/unit-wu-infantry-marine-actions.webp new file mode 100644 index 0000000..be40923 Binary files /dev/null and b/src/assets/images/units/unit-wu-infantry-marine-actions.webp differ diff --git a/src/assets/images/units/unit-wu-infantry-marine.webp b/src/assets/images/units/unit-wu-infantry-marine.webp new file mode 100644 index 0000000..68adc0e Binary files /dev/null and b/src/assets/images/units/unit-wu-infantry-marine.webp differ diff --git a/src/assets/images/units/unit-wu-infantry-river-actions.webp b/src/assets/images/units/unit-wu-infantry-river-actions.webp new file mode 100644 index 0000000..b4b8525 Binary files /dev/null and b/src/assets/images/units/unit-wu-infantry-river-actions.webp differ diff --git a/src/assets/images/units/unit-wu-infantry-river.webp b/src/assets/images/units/unit-wu-infantry-river.webp new file mode 100644 index 0000000..ef9c1d8 Binary files /dev/null and b/src/assets/images/units/unit-wu-infantry-river.webp differ diff --git a/src/assets/images/units/unit-wu-infantry.webp b/src/assets/images/units/unit-wu-infantry.webp new file mode 100644 index 0000000..b3d3fa4 Binary files /dev/null and b/src/assets/images/units/unit-wu-infantry.webp differ diff --git a/src/assets/images/units/unit-wu-officer-actions.webp b/src/assets/images/units/unit-wu-officer-actions.webp new file mode 100644 index 0000000..177eedf Binary files /dev/null and b/src/assets/images/units/unit-wu-officer-actions.webp differ diff --git a/src/assets/images/units/unit-wu-officer-harbor-actions.webp b/src/assets/images/units/unit-wu-officer-harbor-actions.webp new file mode 100644 index 0000000..d80d6e0 Binary files /dev/null and b/src/assets/images/units/unit-wu-officer-harbor-actions.webp differ diff --git a/src/assets/images/units/unit-wu-officer-harbor.webp b/src/assets/images/units/unit-wu-officer-harbor.webp new file mode 100644 index 0000000..b627057 Binary files /dev/null and b/src/assets/images/units/unit-wu-officer-harbor.webp differ diff --git a/src/assets/images/units/unit-wu-officer-river-actions.webp b/src/assets/images/units/unit-wu-officer-river-actions.webp new file mode 100644 index 0000000..2e2f848 Binary files /dev/null and b/src/assets/images/units/unit-wu-officer-river-actions.webp differ diff --git a/src/assets/images/units/unit-wu-officer-river.webp b/src/assets/images/units/unit-wu-officer-river.webp new file mode 100644 index 0000000..e42a29b Binary files /dev/null and b/src/assets/images/units/unit-wu-officer-river.webp differ diff --git a/src/assets/images/units/unit-wu-officer.webp b/src/assets/images/units/unit-wu-officer.webp new file mode 100644 index 0000000..1275827 Binary files /dev/null and b/src/assets/images/units/unit-wu-officer.webp differ diff --git a/src/assets/images/units/unit-wu-strategist-actions.webp b/src/assets/images/units/unit-wu-strategist-actions.webp new file mode 100644 index 0000000..73ad7ea Binary files /dev/null and b/src/assets/images/units/unit-wu-strategist-actions.webp differ diff --git a/src/assets/images/units/unit-wu-strategist-fire-actions.webp b/src/assets/images/units/unit-wu-strategist-fire-actions.webp new file mode 100644 index 0000000..a07c18b Binary files /dev/null and b/src/assets/images/units/unit-wu-strategist-fire-actions.webp differ diff --git a/src/assets/images/units/unit-wu-strategist-fire.webp b/src/assets/images/units/unit-wu-strategist-fire.webp new file mode 100644 index 0000000..7b58964 Binary files /dev/null and b/src/assets/images/units/unit-wu-strategist-fire.webp differ diff --git a/src/assets/images/units/unit-wu-strategist-river-actions.webp b/src/assets/images/units/unit-wu-strategist-river-actions.webp new file mode 100644 index 0000000..9304e2f Binary files /dev/null and b/src/assets/images/units/unit-wu-strategist-river-actions.webp differ diff --git a/src/assets/images/units/unit-wu-strategist-river.webp b/src/assets/images/units/unit-wu-strategist-river.webp new file mode 100644 index 0000000..f201290 Binary files /dev/null and b/src/assets/images/units/unit-wu-strategist-river.webp differ diff --git a/src/assets/images/units/unit-wu-strategist.webp b/src/assets/images/units/unit-wu-strategist.webp new file mode 100644 index 0000000..5720f41 Binary files /dev/null and b/src/assets/images/units/unit-wu-strategist.webp differ diff --git a/src/assets/images/units/unit-wu-yi-actions.webp b/src/assets/images/units/unit-wu-yi-actions.webp new file mode 100644 index 0000000..e9e7466 Binary files /dev/null and b/src/assets/images/units/unit-wu-yi-actions.webp differ diff --git a/src/assets/images/units/unit-wu-yi.webp b/src/assets/images/units/unit-wu-yi.webp new file mode 100644 index 0000000..cf4132d Binary files /dev/null and b/src/assets/images/units/unit-wu-yi.webp differ diff --git a/src/assets/images/units/unit-yan-yan-actions.webp b/src/assets/images/units/unit-yan-yan-actions.webp new file mode 100644 index 0000000..5cdbd96 Binary files /dev/null and b/src/assets/images/units/unit-yan-yan-actions.webp differ diff --git a/src/assets/images/units/unit-yan-yan.webp b/src/assets/images/units/unit-yan-yan.webp new file mode 100644 index 0000000..5c099ea Binary files /dev/null and b/src/assets/images/units/unit-yan-yan.webp differ diff --git a/src/assets/images/units/unit-yi-ji-actions.webp b/src/assets/images/units/unit-yi-ji-actions.webp new file mode 100644 index 0000000..3f32d37 Binary files /dev/null and b/src/assets/images/units/unit-yi-ji-actions.webp differ diff --git a/src/assets/images/units/unit-yi-ji.webp b/src/assets/images/units/unit-yi-ji.webp new file mode 100644 index 0000000..aaf72ec Binary files /dev/null and b/src/assets/images/units/unit-yi-ji.webp differ diff --git a/src/assets/images/units/unit-zhang-fei-actions.webp b/src/assets/images/units/unit-zhang-fei-actions.webp new file mode 100644 index 0000000..ad2591f Binary files /dev/null and b/src/assets/images/units/unit-zhang-fei-actions.webp differ diff --git a/src/assets/images/units/unit-zhang-fei.webp b/src/assets/images/units/unit-zhang-fei.webp new file mode 100644 index 0000000..5cc48ef Binary files /dev/null and b/src/assets/images/units/unit-zhang-fei.webp differ diff --git a/src/assets/images/units/unit-zhao-yun-actions.webp b/src/assets/images/units/unit-zhao-yun-actions.webp new file mode 100644 index 0000000..9e224c3 Binary files /dev/null and b/src/assets/images/units/unit-zhao-yun-actions.webp differ diff --git a/src/assets/images/units/unit-zhao-yun.webp b/src/assets/images/units/unit-zhao-yun.webp new file mode 100644 index 0000000..358680a Binary files /dev/null and b/src/assets/images/units/unit-zhao-yun.webp differ diff --git a/src/assets/images/units/unit-zhuge-liang-actions.webp b/src/assets/images/units/unit-zhuge-liang-actions.webp new file mode 100644 index 0000000..d28585e Binary files /dev/null and b/src/assets/images/units/unit-zhuge-liang-actions.webp differ diff --git a/src/assets/images/units/unit-zhuge-liang.webp b/src/assets/images/units/unit-zhuge-liang.webp new file mode 100644 index 0000000..01306fc Binary files /dev/null and b/src/assets/images/units/unit-zhuge-liang.webp differ diff --git a/src/game/data/battleForecast.ts b/src/game/data/battleForecast.ts new file mode 100644 index 0000000..04be12f --- /dev/null +++ b/src/game/data/battleForecast.ts @@ -0,0 +1,202 @@ +export type IntentForecastKind = 'attack' | 'usable' | 'approach' | 'wait'; + +export type IntentForecastSnapshot = { + enemyId: string; + kind: IntentForecastKind; + targetId?: string; + usableId?: string; + damage: number; + hitRate: number; + criticalRate?: number; +}; + +export type IntentRiskSummary = { + immediateCount: number; + approachCount: number; + expectedDamage: number; + rawDamage: number; + criticalAdjustedMaxDamage: number; + lethal: boolean; + immediateEnemyIds: string[]; +}; + +export type MoveIntentRiskTone = 'safer' | 'steady' | 'riskier'; + +export type MoveIntentRiskDelta = { + targetId: string; + before: IntentRiskSummary; + after: IntentRiskSummary; + immediateDelta: number; + expectedDamageDelta: number; + teamExpectedDamageBefore: number; + teamExpectedDamageAfter: number; + teamExpectedDamageDelta: number; + retargetedToCount: number; + retargetedAwayCount: number; + neutralizedImmediateCount: number; + tone: MoveIntentRiskTone; +}; + +export type CombatExchangeProjectionInput = { + attackerHp: number; + defenderHp: number; + attackDamage: number; + attackHitRate: number; + attackCriticalRate: number; + counter?: { + damage: number; + hitRate: number; + criticalRate: number; + }; +}; + +export type CombatExchangeProjection = { + defenderHpAfterHit: number; + defenderHpAfterCriticalHit: number; + attackerHpAfterCounterHit: number; + attackerHpAfterCounterCriticalHit: number; + attackExpectedDamage: number; + counterExpectedDamage: number; + attackLethalOnHit: boolean; + attackLethalOnCritical: boolean; + counterLethalOnHit: boolean; + counterLethalOnCritical: boolean; + counterCondition: 'none' | 'if-defender-survives' | 'on-primary-miss'; +}; + +const isImmediateIntent = (snapshot: IntentForecastSnapshot) => + snapshot.kind === 'attack' || snapshot.kind === 'usable'; + +const criticalDamage = (damage: number) => Math.round(Math.max(0, damage) * 1.5); + +const expectedDamage = (snapshot: IntentForecastSnapshot) => + isImmediateIntent(snapshot) + ? Math.max(0, snapshot.damage) * + Math.max(0, Math.min(100, snapshot.hitRate)) / 100 * + (1 + Math.max(0, Math.min(100, snapshot.criticalRate ?? 0)) / 200) + : 0; + +export function summarizeIntentRisk( + snapshots: readonly IntentForecastSnapshot[], + targetId: string, + targetHp: number +): IntentRiskSummary { + const targeted = snapshots.filter((snapshot) => snapshot.targetId === targetId); + const immediate = targeted.filter(isImmediateIntent); + const expected = Math.round(immediate.reduce((total, snapshot) => total + expectedDamage(snapshot), 0)); + const rawDamage = immediate.reduce((total, snapshot) => total + Math.max(0, snapshot.damage), 0); + const criticalAdjustedMaxDamage = immediate.reduce( + (total, snapshot) => total + ( + (snapshot.criticalRate ?? 0) > 0 ? criticalDamage(snapshot.damage) : Math.max(0, snapshot.damage) + ), + 0 + ); + return { + immediateCount: immediate.length, + approachCount: targeted.filter((snapshot) => snapshot.kind === 'approach').length, + expectedDamage: expected, + rawDamage, + criticalAdjustedMaxDamage, + lethal: targetHp > 0 && criticalAdjustedMaxDamage >= targetHp, + immediateEnemyIds: immediate.map((snapshot) => snapshot.enemyId) + }; +} + +export function compareMoveIntentRisk( + beforeSnapshots: readonly IntentForecastSnapshot[], + afterSnapshots: readonly IntentForecastSnapshot[], + targetId: string, + targetHp: number +): MoveIntentRiskDelta { + const before = summarizeIntentRisk(beforeSnapshots, targetId, targetHp); + const after = summarizeIntentRisk(afterSnapshots, targetId, targetHp); + const beforeByEnemy = new Map(beforeSnapshots.map((snapshot) => [snapshot.enemyId, snapshot] as const)); + const afterByEnemy = new Map(afterSnapshots.map((snapshot) => [snapshot.enemyId, snapshot] as const)); + const enemyIds = new Set([...beforeByEnemy.keys(), ...afterByEnemy.keys()]); + let retargetedToCount = 0; + let retargetedAwayCount = 0; + let neutralizedImmediateCount = 0; + + enemyIds.forEach((enemyId) => { + const previous = beforeByEnemy.get(enemyId); + const next = afterByEnemy.get(enemyId); + if (previous?.targetId !== targetId && next?.targetId === targetId) { + retargetedToCount += 1; + } + if (previous?.targetId === targetId && next?.targetId !== targetId) { + retargetedAwayCount += 1; + } + if (previous && isImmediateIntent(previous) && (!next || !isImmediateIntent(next))) { + neutralizedImmediateCount += 1; + } + }); + + const teamExpectedDamageBefore = Math.round(beforeSnapshots.reduce((total, snapshot) => total + expectedDamage(snapshot), 0)); + const teamExpectedDamageAfter = Math.round(afterSnapshots.reduce((total, snapshot) => total + expectedDamage(snapshot), 0)); + const immediateDelta = after.immediateCount - before.immediateCount; + const expectedDamageDelta = after.expectedDamage - before.expectedDamage; + const teamExpectedDamageDelta = teamExpectedDamageAfter - teamExpectedDamageBefore; + const beforeRiskScore = before.expectedDamage * 2 + teamExpectedDamageBefore + before.immediateCount * 5 + (before.lethal ? 50 : 0); + const afterRiskScore = after.expectedDamage * 2 + teamExpectedDamageAfter + after.immediateCount * 5 + (after.lethal ? 50 : 0); + const tone: MoveIntentRiskTone = afterRiskScore < beforeRiskScore + ? 'safer' + : afterRiskScore > beforeRiskScore + ? 'riskier' + : 'steady'; + + return { + targetId, + before, + after, + immediateDelta, + expectedDamageDelta, + teamExpectedDamageBefore, + teamExpectedDamageAfter, + teamExpectedDamageDelta, + retargetedToCount, + retargetedAwayCount, + neutralizedImmediateCount, + tone + }; +} + +const expectedCriticalAdjustedDamage = (damage: number, hitRate: number, criticalRate: number) => + Math.round( + Math.max(0, damage) * + Math.max(0, Math.min(100, hitRate)) / 100 * + (1 + Math.max(0, Math.min(100, criticalRate)) / 200) + ); + +export function buildCombatExchangeProjection(input: CombatExchangeProjectionInput): CombatExchangeProjection { + const defenderHpAfterHit = Math.max(0, input.defenderHp - Math.max(0, input.attackDamage)); + const defenderHpAfterCriticalHit = Math.max(0, input.defenderHp - criticalDamage(input.attackDamage)); + const attackLethalOnHit = input.defenderHp > 0 && defenderHpAfterHit <= 0; + const attackLethalOnCritical = input.attackCriticalRate > 0 && input.defenderHp > 0 && defenderHpAfterCriticalHit <= 0; + const counterDamage = input.counter?.damage ?? 0; + const attackerHpAfterCounterHit = Math.max(0, input.attackerHp - Math.max(0, counterDamage)); + const attackerHpAfterCounterCriticalHit = Math.max(0, input.attackerHp - criticalDamage(counterDamage)); + const counterLethalOnHit = Boolean(input.counter && input.attackerHp > 0 && attackerHpAfterCounterHit <= 0); + const counterLethalOnCritical = Boolean( + input.counter && input.counter.criticalRate > 0 && input.attackerHp > 0 && attackerHpAfterCounterCriticalHit <= 0 + ); + + return { + defenderHpAfterHit, + defenderHpAfterCriticalHit, + attackerHpAfterCounterHit, + attackerHpAfterCounterCriticalHit, + attackExpectedDamage: expectedCriticalAdjustedDamage(input.attackDamage, input.attackHitRate, input.attackCriticalRate), + counterExpectedDamage: input.counter + ? expectedCriticalAdjustedDamage(input.counter.damage, input.counter.hitRate, input.counter.criticalRate) + : 0, + attackLethalOnHit, + attackLethalOnCritical, + counterLethalOnHit, + counterLethalOnCritical, + counterCondition: !input.counter + ? 'none' + : attackLethalOnHit + ? 'on-primary-miss' + : 'if-defender-survives' + }; +} diff --git a/src/game/data/unitActionAssetPolicy.json b/src/game/data/unitActionAssetPolicy.json new file mode 100644 index 0000000..4acee4c --- /dev/null +++ b/src/game/data/unitActionAssetPolicy.json @@ -0,0 +1,15 @@ +{ + "version": 3, + "sourceFrameSize": 313, + "optimizedFrameSize": 192, + "baseColumns": 16, + "baseRows": 4, + "actionColumns": 36, + "actionRows": 4, + "format": "webp", + "lossless": true, + "method": 6, + "optimizeAll": true, + "expectedBaseAssetCount": 91, + "expectedActionAssetCount": 91 +} diff --git a/src/game/data/unitAssets.ts b/src/game/data/unitAssets.ts index 865a114..65b7913 100644 --- a/src/game/data/unitAssets.ts +++ b/src/game/data/unitAssets.ts @@ -1,11 +1,13 @@ -import Phaser from 'phaser'; +import type Phaser from 'phaser'; +import unitActionAssetPolicy from './unitActionAssetPolicy.json'; +import { cancelLoaderFiles, captureLoaderFiles, releaseTextureSource } from '../render/loaderLifecycle'; export type UnitDirection = 'south' | 'east' | 'north' | 'west'; const unitIdleFrameCount = 8; const unitWalkFrameCount = 8; export const unitBaseFramesPerDirection = unitIdleFrameCount + unitWalkFrameCount; -export const unitSheetFrameSize = 313; +export const unitSheetFrameSize = unitActionAssetPolicy.optimizedFrameSize; const unitWalkFrameRate = 10; const unitSheetRows: Record = { south: 0, @@ -17,8 +19,12 @@ const unitSheetRows: Record = { type UnitSheetAsset = { key: string; url: string; + frameSize: number; + format: 'png' | 'webp'; actionKey: string; actionUrl: string; + actionFrameSize: number; + actionFormat: 'png' | 'webp'; }; export type UnitActionSheetLoadResult = { @@ -35,33 +41,138 @@ export type UnitActionSheetLoadOptions = { const defaultUnitActionSheetWatchdogMs = 15_000; -const unitSheetModules = import.meta.glob('../../assets/images/units/unit-*.png', { +const unitWebpSheetModules = import.meta.glob('../../assets/images/units/unit-*.webp', { eager: true, import: 'default' }) as Record; +const unitSheetModules = { ...unitWebpSheetModules }; + function textureKeyFromPath(path: string) { const fileName = path.split('/').pop() ?? path; - return fileName.replace(/\.png$/i, ''); + return fileName.replace(/\.(?:png|webp)$/i, ''); } -const unitSheetUrls = Object.fromEntries( - Object.entries(unitSheetModules).map(([path, url]) => [textureKeyFromPath(path), url]) -); +function unitSheetFormatFromPath(path: string): 'png' | 'webp' { + return path.toLowerCase().endsWith('.webp') ? 'webp' : 'png'; +} -const unitSheets = Object.entries(unitSheetUrls) - .filter(([key]) => !key.endsWith('-actions')) - .map(([key, url]) => ({ - key, - url, - actionKey: `${key}-actions`, - actionUrl: unitSheetUrls[`${key}-actions`] - })) - .filter((sheet): sheet is UnitSheetAsset => Boolean(sheet.actionUrl)) +const unitSheetEntries = Object.entries(unitSheetModules).map(([path, url]) => ({ + key: textureKeyFromPath(path), + url, + format: unitSheetFormatFromPath(path) +})); +const unitSheetEntriesByKey = new Map(unitSheetEntries.map((entry) => [entry.key, entry])); + +const unitSheets = unitSheetEntries + .filter(({ key }) => !key.endsWith('-actions')) + .map(({ key, url }) => { + const actionKey = `${key}-actions`; + const actionEntry = unitSheetEntriesByKey.get(actionKey); + return actionEntry + ? { + key, + url, + frameSize: unitActionAssetPolicy.optimizedFrameSize, + format: unitActionAssetPolicy.format, + actionKey, + actionUrl: actionEntry.url, + actionFrameSize: actionEntry.format === unitActionAssetPolicy.format + ? unitActionAssetPolicy.optimizedFrameSize + : unitSheetFrameSize, + actionFormat: actionEntry.format + } + : undefined; + }) + .filter((sheet): sheet is UnitSheetAsset => Boolean(sheet)) .sort((left, right) => left.key.localeCompare(right.key)); const unitSheetAssetsByKey = new Map(unitSheets.map((sheet) => [sheet.key, sheet])); const pendingUnitTextureKeys = new Set(); +const unitTextureCleanupScenes = new WeakSet(); + +export type UnitActionSheetAssetInfo = { + key: string; + actionKey: string; + url: string; + frameSize: number; + format: 'png' | 'webp'; +}; + +export type UnitBaseSheetAssetInfo = { + key: string; + url: string; + frameSize: number; + format: 'png' | 'webp'; +}; + +export function unitBaseSheetAssetInfo(key: string): UnitBaseSheetAssetInfo | undefined { + const sheet = unitSheetAssetsByKey.get(key); + return sheet + ? { + key: sheet.key, + url: sheet.url, + frameSize: sheet.frameSize, + format: sheet.format + } + : undefined; +} + +export function unitActionSheetAssetInfo(key: string): UnitActionSheetAssetInfo | undefined { + const sheet = unitSheetAssetsByKey.get(key); + return sheet + ? { + key: sheet.key, + actionKey: sheet.actionKey, + url: sheet.actionUrl, + frameSize: sheet.actionFrameSize, + format: sheet.actionFormat + } + : undefined; +} + +export function releaseUnitActionSheetTextures(scene: Phaser.Scene, keepKeys: Iterable = []) { + const keepActionKeys = new Set(Array.from(keepKeys, (key) => `${key}-actions`)); + scene.textures.getTextureKeys().forEach((textureKey) => { + if (textureKey.startsWith('unit-') && textureKey.endsWith('-actions') && !keepActionKeys.has(textureKey)) { + releaseTextureSource(scene, textureKey); + pendingUnitTextureKeys.delete(textureKey); + } + }); +} + +export function releaseUnitBaseSheetTextures(scene: Phaser.Scene, keepKeys: Iterable = []) { + const keepBaseKeys = new Set(keepKeys); + scene.textures.getTextureKeys().forEach((textureKey) => { + if (textureKey.startsWith('unit-') && !textureKey.endsWith('-actions') && !keepBaseKeys.has(textureKey)) { + releaseUnitBaseSheetAnimations(scene, textureKey); + releaseTextureSource(scene, textureKey); + pendingUnitTextureKeys.delete(textureKey); + } + }); +} + +function releaseUnitBaseSheetAnimations(scene: Phaser.Scene, textureKey: string) { + (['south', 'east', 'north', 'west'] as UnitDirection[]).forEach((direction) => { + [`${textureKey}-walk-${direction}`, `${textureKey}-idle-${direction}`].forEach((animationKey) => { + if (scene.anims.exists(animationKey)) { + scene.anims.remove(animationKey); + } + }); + }); +} + +function registerUnitTextureCleanup(scene: Phaser.Scene) { + if (unitTextureCleanupScenes.has(scene)) { + return; + } + unitTextureCleanupScenes.add(scene); + scene.events.once('shutdown', () => { + releaseUnitActionSheetTextures(scene); + releaseUnitBaseSheetTextures(scene); + unitTextureCleanupScenes.delete(scene); + }); +} export function unitTextureVariantKeys(baseKey: string) { const prefix = `${baseKey}-`; @@ -87,17 +198,21 @@ export function waitForUnitBaseSheets(scene: Phaser.Scene, keys: Iterable, onReady: () => void) { const uniqueKeys = Array.from(new Set(keys)); + if (scene.scene.key === 'BattleScene') { + registerUnitTextureCleanup(scene); + releaseUnitBaseSheetTextures(scene, uniqueKeys); + } if (unitBaseSheetsReady(scene, uniqueKeys)) { onReady(); return; } - const pendingLoads: Array<{ key: string; url: string }> = []; + const pendingLoads: Array<{ key: string; url: string; frameSize: number }> = []; 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 }); + pendingLoads.push({ key: sheet.key, url: sheet.url, frameSize: sheet.frameSize }); } }); @@ -109,20 +224,30 @@ export function loadUnitBaseSheets(scene: Phaser.Scene, keys: Iterable, const clearPendingLoads = () => { pendingLoads.forEach(({ key }) => pendingUnitTextureKeys.delete(key)); }; - - scene.load.once('complete', () => { + const handleLoadError = () => clearPendingLoads(); + const handleShutdown = () => { + scene.load.off('complete', handleComplete); + scene.load.off('loaderror', handleLoadError); + clearPendingLoads(); + }; + const handleComplete = () => { + scene.load.off('loaderror', handleLoadError); + scene.events.off('shutdown', handleShutdown); clearPendingLoads(); const missing = uniqueKeys.filter((key) => !scene.textures.exists(key)); if (missing.length > 0) { console.warn(`Failed to load unit base sheet textures: ${missing.join(', ')}`); } onReady(); - }); - scene.load.once('loaderror', clearPendingLoads); - pendingLoads.forEach(({ key, url }) => { + }; + + scene.load.once('complete', handleComplete); + scene.load.once('loaderror', handleLoadError); + scene.events.once('shutdown', handleShutdown); + pendingLoads.forEach(({ key, url, frameSize }) => { scene.load.spritesheet(key, url, { - frameWidth: unitSheetFrameSize, - frameHeight: unitSheetFrameSize + frameWidth: frameSize, + frameHeight: frameSize }); }); scene.load.start(); @@ -146,17 +271,19 @@ export function loadUnitActionSheets( options: UnitActionSheetLoadOptions = {} ) { const uniqueKeys = Array.from(new Set(keys)); + releaseUnitActionSheetTextures(scene, uniqueKeys); + registerUnitTextureCleanup(scene); if (unitActionSheetsReady(scene, uniqueKeys)) { onComplete({ readyKeys: uniqueKeys, missingKeys: [], failedKeys: [], timedOutKeys: [], timedOut: false }); return; } - const pendingLoads: Array<{ key: string; url: string }> = []; + const pendingLoads: Array<{ key: string; url: string; frameSize: number }> = []; uniqueKeys.forEach((key) => { const sheet = unitSheetAssetsByKey.get(key) ?? missingUnitSheet(key); if (!scene.textures.exists(sheet.actionKey) && !pendingUnitTextureKeys.has(sheet.actionKey)) { pendingUnitTextureKeys.add(sheet.actionKey); - pendingLoads.push({ key: sheet.actionKey, url: sheet.actionUrl }); + pendingLoads.push({ key: sheet.actionKey, url: sheet.actionUrl, frameSize: sheet.actionFrameSize }); } }); @@ -166,6 +293,7 @@ export function loadUnitActionSheets( let settled = false; let pollTimer: ReturnType | undefined; let watchdogTimer: ReturnType | undefined; + let ownedLoaderFiles: Phaser.Loader.File[] = []; const clearPendingLoads = () => { pendingLoads.forEach(({ key }) => pendingUnitTextureKeys.delete(key)); }; @@ -189,6 +317,9 @@ export function loadUnitActionSheets( } settled = true; cleanup(); + if (timedOut || !notify) { + cancelLoaderFiles(scene, ownedLoaderFiles, timedOut); + } if (notify) { finishUnitActionSheetLoad(scene, uniqueKeys, failedActionKeys, timedOut, onComplete); } @@ -224,12 +355,13 @@ export function loadUnitActionSheets( return; } - pendingLoads.forEach(({ key, url }) => { + pendingLoads.forEach(({ key, url, frameSize }) => { scene.load.spritesheet(key, url, { - frameWidth: unitSheetFrameSize, - frameHeight: unitSheetFrameSize + frameWidth: frameSize, + frameHeight: frameSize }); }); + ownedLoaderFiles = captureLoaderFiles(scene, pendingLoads.map(({ key }) => key)); scene.load.start(); } @@ -269,7 +401,7 @@ function finishUnitActionSheetLoad( console.warn(`Failed to load unit action sheets; base-sheet fallback will be used: ${failedKeys.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(', ')}`); + console.info(`Unit action sheet loading exceeded the startup budget; pending requests were cancelled and the base-sheet fallback is active: ${timedOutKeys.join(', ')}`); } onComplete({ readyKeys: keys.filter((key) => !missingKeys.includes(key)), diff --git a/src/game/data/unitProgress.ts b/src/game/data/unitProgress.ts index 1cf9458..9b18836 100644 --- a/src/game/data/unitProgress.ts +++ b/src/game/data/unitProgress.ts @@ -14,6 +14,19 @@ const levelUpStatKeysByClass: Record> = { rebelLeader: ['might', 'intelligence', 'leadership'] }; +export function progressUnitStatsForLevelUps( + stats: UnitStats, + classKey: UnitClassKey, + levelUps: number +): UnitStats { + const safeLevelUps = Math.max(0, Math.floor(levelUps)); + const progressedStatKeys = new Set(levelUpStatKeysByClass[classKey]); + return (Object.keys(stats) as Array).reduce((next, key) => { + next[key] = Math.min(255, stats[key] + (progressedStatKeys.has(key) ? safeLevelUps : 0)); + return next; + }, {} as UnitStats); +} + export function applyCampaignUnitProgress(scenarioUnit: UnitData, progress?: UnitData): UnitData { const cloned = cloneUnitProgressData(scenarioUnit); if (!progress || scenarioUnit.faction !== 'ally' || progress.faction !== 'ally' || progress.id !== scenarioUnit.id) { @@ -22,10 +35,9 @@ export function applyCampaignUnitProgress(scenarioUnit: UnitData, progress?: Uni const level = Math.max(scenarioUnit.level, progress.level); const levelUps = level - scenarioUnit.level; - const progressedStatKeys = new Set(levelUpStatKeysByClass[scenarioUnit.classKey]); + const authoredStats = progressUnitStatsForLevelUps(scenarioUnit.stats, scenarioUnit.classKey, levelUps); const stats = (Object.keys(scenarioUnit.stats) as Array).reduce((next, key) => { - const authoredGrowthFloor = scenarioUnit.stats[key] + (progressedStatKeys.has(key) ? levelUps : 0); - next[key] = Math.min(255, Math.max(progress.stats[key], authoredGrowthFloor)); + next[key] = Math.max(progress.stats[key], authoredStats[key]); return next; }, {} as UnitStats); const maxHp = Math.max(progress.maxHp, scenarioUnit.maxHp + levelUps * 2); diff --git a/src/game/render/loaderLifecycle.ts b/src/game/render/loaderLifecycle.ts new file mode 100644 index 0000000..00c681b --- /dev/null +++ b/src/game/render/loaderLifecycle.ts @@ -0,0 +1,119 @@ +import type Phaser from 'phaser'; + +// Phaser 3.90 marks completed-or-terminal loader files from state 17 onward. +// Keeping this local avoids evaluating Phaser's browser-only runtime in Node-based data checks. +const loaderFileCompleteState = 17; + +function loaderFiles(scene: Phaser.Scene) { + return [ + ...scene.load.list.getArray(), + ...scene.load.inflight.getArray(), + ...scene.load.queue.getArray() + ]; +} + +export function captureLoaderFiles(scene: Phaser.Scene, keys: Iterable) { + const requestedKeys = new Set(keys); + return loaderFiles(scene).filter((file) => requestedKeys.has(String(file.key))); +} + +function releaseImageSource(source: unknown) { + if (typeof HTMLImageElement !== 'undefined' && source instanceof HTMLImageElement) { + source.onload = null; + source.onerror = null; + const url = source.currentSrc || source.src; + if (url.startsWith('blob:') && typeof URL !== 'undefined') { + URL.revokeObjectURL(url); + } + source.removeAttribute('src'); + return; + } + + if (typeof ImageBitmap !== 'undefined' && source instanceof ImageBitmap) { + source.close(); + return; + } + + if (typeof HTMLCanvasElement !== 'undefined' && source instanceof HTMLCanvasElement) { + source.width = 1; + source.height = 1; + } +} + +export function releaseTextureSource(scene: Phaser.Scene, textureKey: string) { + if (!scene.textures.exists(textureKey)) { + return false; + } + + const texture = scene.textures.get(textureKey); + const sourceImages = [...texture.source, ...texture.dataSource].map((source) => source.image); + scene.textures.remove(texture); + sourceImages.forEach(releaseImageSource); + return true; +} + +function cancelImageProcessing(file: Phaser.Loader.File) { + if (typeof HTMLImageElement === 'undefined' || !(file.data instanceof HTMLImageElement)) { + return; + } + + const image = file.data; + image.onload = null; + image.onerror = null; + releaseImageSource(image); +} + +function cancelXhr(file: Phaser.Loader.File) { + const xhr = file.xhrLoader; + if (!xhr) { + return; + } + + file.resetXHR(); + xhr.ontimeout = null; + xhr.onabort = null; + xhr.onreadystatechange = null; + if (xhr.readyState !== XMLHttpRequest.DONE) { + try { + xhr.abort(); + } catch { + // The browser may already have finalized the request between the state check and abort. + } + } + file.xhrLoader = null; +} + +export function cancelLoaderFiles( + scene: Phaser.Scene, + files: Iterable, + finalizeEmptyBatch = false +) { + const uniqueFiles = new Set(files); + let cancelledCount = 0; + + uniqueFiles.forEach((file) => { + if (file.state >= loaderFileCompleteState || scene.textures.exists(String(file.key))) { + return; + } + + scene.load.list.delete(file); + scene.load.inflight.delete(file); + scene.load.queue.delete(file); + cancelXhr(file); + cancelImageProcessing(file); + file.destroy(); + cancelledCount += 1; + }); + + if ( + finalizeEmptyBatch && + scene.load.isLoading() && + scene.load.list.size === 0 && + scene.load.inflight.size === 0 && + scene.load.queue.size === 0 + ) { + scene.load.loadComplete(); + } + + return cancelledCount; +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 68712fb..45f94e3 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1,7 +1,7 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; import { battleMapAssets } from '../data/battleMapAssets'; -import { endingEpiloguePages, firstBattleVictoryPages, type BattleBond, type UnitData, type UnitStats } from '../data/scenario'; +import { firstBattleVictoryPages, type BattleBond, type UnitData, type UnitStats } from '../data/scenario'; import { defaultBattleScenario, getBattleScenario, @@ -17,6 +17,12 @@ import { isEnemyIntentCounterplayAvailable, isEnemyIntentForecastAvailable } from '../data/enemyIntent'; +import { + buildCombatExchangeProjection, + compareMoveIntentRisk, + type CombatExchangeProjection, + type MoveIntentRiskDelta +} from '../data/battleForecast'; import { getTerrainRule, getUnitClass, type TerrainType, type UnitClassKey } from '../data/battleRules'; import { ensureUnitAnimations, @@ -57,7 +63,10 @@ import { type EquipmentSlot, type ItemDefinition } from '../data/battleItems'; -import { applyCampaignUnitProgress as mergeCampaignUnitProgress } from '../data/unitProgress'; +import { + applyCampaignUnitProgress as mergeCampaignUnitProgress, + progressUnitStatsForLevelUps +} from '../data/unitProgress'; import { applySortieDeploymentPlan, createSortieDeploymentPlan, @@ -126,13 +135,17 @@ import { } from '../state/campaignState'; import { normalizeBattleSaveSlot, - parseBattleSaveState, type BattleSaveCoreResonanceStats, type BattleSaveState, type BattleSaveTacticalCommandRole, type BattleSaveTacticalCommandState, type BattleSaveUnitStats } from '../state/battleSaveState'; +import { + clearCampaignBattleSavesForSlot, + clearBattleSaveStorageForBattle, + readBattleSaveStorageCandidate +} from '../state/battleSaveStorage'; import { combatPresentationImportanceReason, combatPresentationModeLabel, @@ -144,6 +157,7 @@ import { type CombatPresentationMode } from '../settings/combatPresentation'; import { palette } from '../ui/palette'; +import { cancelLoaderFiles, captureLoaderFiles, releaseTextureSource } from '../render/loaderLifecycle'; import { startLazyScene } from './lazyScenes'; const battleReferenceWidth = 1280; @@ -1474,6 +1488,7 @@ type DeploymentSlot = { type BattleSceneData = { battleId?: string; + resumeBattleSaveSlot?: number; selectedSortieUnitIds?: string[]; sortieFormationAssignments?: SortieFormationAssignments; sortieItemAssignments?: CampaignSortieItemAssignments; @@ -1496,6 +1511,16 @@ type PendingMove = { toY: number; intentBefore: EnemyIntentSnapshot[]; intentAfterMove: EnemyIntentSnapshot[]; + intentRisk: MoveIntentRiskDelta; +}; + +type BattleKeyboardCursorMode = 'move' | 'damage' | 'support'; + +type BattleKeyboardCursor = { + mode: BattleKeyboardCursorMode; + x: number; + y: number; + targetId?: string; }; type FirstBattleTutorialStep = 'select-unit' | 'move' | 'attack-command' | 'target-preview'; @@ -1650,7 +1675,7 @@ type ResultButtonView = { type ResultButtonTone = 'primary' | 'secondary' | 'utility' | 'ghost'; type ResultButtonKind = 'continue' | 'retry' | 'title' | 'detail'; type ResultSettlementStatus = 'hidden' | 'pending' | 'animating' | 'complete'; -type ResultContinueDestination = 'victory-story' | 'camp' | 'epilogue'; +type ResultContinueDestination = 'victory-story' | 'camp'; type ThreatTile = { x: number; @@ -1821,6 +1846,12 @@ type CombatPreview = { matchupLabel: string; }; +type CombatExchangePreview = { + attack: CombatPreview; + counter?: CombatPreview; + projection: CombatExchangeProjection; +}; + type CombatResult = CombatPreview & { expectedDamage: number; previousDefenderHp: number; @@ -3498,6 +3529,7 @@ export class BattleScene extends Phaser.Scene { private scenarioCombatAssetLoadCompletedAt?: number; private scenarioCombatAssetLoadGeneration = 0; private scenarioCombatAssetSettlementCount = 0; + private resumeBattleSaveSlot?: number; private pendingDeploymentConfirmation = false; private turnNumber = 1; private activeFaction: ActiveFaction = 'ally'; @@ -3505,6 +3537,10 @@ export class BattleScene extends Phaser.Scene { private targetingAction?: DamageCommand; private selectedUsable?: BattleUsable; private lockedTargetPreview?: LockedTargetPreview; + private keyboardBattleCursor?: BattleKeyboardCursor; + private moveIntentPreviewCache = new Map(); + private lastMoveIntentRisk?: MoveIntentRiskDelta; + private lastCombatExchangePreview?: CombatExchangePreview; private bondChainReadyMarkerViews: BondChainReadyMarkerView[] = []; private bondChainReadyPartnerMarkers = new Map(); private bondChainReadyFocusedPreview?: BondChainReadyFocusedPreview; @@ -3686,6 +3722,10 @@ export class BattleScene extends Phaser.Scene { init(data?: BattleSceneData) { configureBattleScenario(getBattleScenario(data?.battleId)); + const requestedResumeSlot = data?.resumeBattleSaveSlot; + this.resumeBattleSaveSlot = typeof requestedResumeSlot === 'number' && Number.isInteger(requestedResumeSlot) + ? normalizeBattleSaveSlot(requestedResumeSlot, campaignSaveSlotCount) + : undefined; this.scenarioCombatAssetLoadGeneration += 1; this.scenarioCombatAssetStatus = 'idle'; this.scenarioCombatAssetCallbacks = []; @@ -3703,6 +3743,51 @@ export class BattleScene extends Phaser.Scene { this.bondPanelLayout = undefined; this.threatPanelLayout = undefined; this.terrainPanelLayout = undefined; + this.sidePanelObjects = []; + this.commandButtons = []; + this.commandMenuObjects = []; + this.deploymentObjects = []; + this.mapMenuObjects = []; + this.mapMenuLayout = undefined; + this.tacticalInitiativePanelRoot = undefined; + this.tacticalInitiativePanelObjects = []; + this.tacticalInitiativePanelBounds = undefined; + this.tacticalInitiativeCardBounds = {}; + this.saveSlotPanelObjects = []; + this.saveSlotPanelMode = undefined; + this.turnPromptObjects = []; + this.turnPromptMode = undefined; + this.turnPromptPrimaryAction = undefined; + this.turnPromptSecondaryAction = undefined; + this.turnText = undefined; + this.battleTitleText = undefined; + this.objectiveTrackerText = undefined; + this.objectiveTrackerSubText = undefined; + this.markers = []; + this.objectiveMarkerObjects = []; + this.bondChainReadyMarkerViews = []; + this.bondChainReadyPartnerMarkers.clear(); + this.bondChainReadyFocusedPreview = undefined; + this.unitViews.clear(); + this.facingIndicatorObjects = []; + this.facingIndicatorUnitId = undefined; + this.mapBackground = undefined; + this.mapMask = undefined; + this.terrainTileViews = []; + this.miniMapLayout = undefined; + this.miniMapObjects = []; + this.miniMapUnitDots.clear(); + this.miniMapViewport = undefined; + this.miniMapVisible = true; + this.recentActionLogLayout = undefined; + this.edgeScrollElapsed = 0; + this.pointerFeedbackMarker = undefined; + this.pointerFeedbackTile = undefined; + this.pointerFeedbackHintBg = undefined; + this.pointerFeedbackHintText = undefined; + this.lastPointerFeedbackKey = undefined; + this.suppressNextLeftClick = false; + this.debugOverlay = undefined; this.sideQuickTabObjects = []; this.activeSideQuickTab = 'roster'; this.hoveredSideQuickTab = undefined; @@ -3821,6 +3906,10 @@ export class BattleScene extends Phaser.Scene { this.targetingAction = undefined; this.selectedUsable = undefined; this.lockedTargetPreview = undefined; + this.keyboardBattleCursor = undefined; + this.moveIntentPreviewCache.clear(); + this.lastMoveIntentRisk = undefined; + this.lastCombatExchangePreview = undefined; this.battleOutcome = undefined; this.phase = 'idle'; this.battleSpeed = this.loadBattleSpeed(); @@ -3841,7 +3930,10 @@ export class BattleScene extends Phaser.Scene { this.handleLeftClick(pointer); } }); - this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => this.updatePointerFeedback(pointer)); + this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => { + this.keyboardBattleCursor = undefined; + this.updatePointerFeedback(pointer); + }); this.input.keyboard?.on('keydown-ENTER', () => { if (this.tacticalCommandCutInObjects.length > 0) { return; @@ -3854,7 +3946,9 @@ export class BattleScene extends Phaser.Scene { return; } - this.confirmLockedTargetPreview(); + if (!this.confirmLockedTargetPreview()) { + this.activateKeyboardBattleCursor(); + } }); this.input.keyboard?.on('keydown-ESC', () => { if (this.tacticalCommandCutInObjects.length > 0) { @@ -3934,6 +4028,9 @@ export class BattleScene extends Phaser.Scene { if (this.handleTacticalInitiativeKey(event)) { return; } + if (this.handleBattleKeyboardNavigation(event)) { + return; + } this.handleSideQuickTabKey(event); }); this.input.keyboard?.on('keydown-SPACE', (event: KeyboardEvent) => { @@ -3980,6 +4077,16 @@ export class BattleScene extends Phaser.Scene { this.drawSidePanel(); this.drawDebugSpritePreview(); this.updateTurnText(); + const resumeSlot = this.resumeBattleSaveSlot; + this.resumeBattleSaveSlot = undefined; + if (resumeSlot) { + if (this.readBattleSaveState(resumeSlot, true)) { + this.time.delayedCall(0, () => this.loadBattleState(resumeSlot)); + this.time.delayedCall(250, () => this.ensureScenarioCombatAssets()); + this.scheduleDebugForcedBattleOutcome(); + return; + } + } if (this.shouldShowPreBattleDeployment()) { this.showPreBattleDeployment(); this.time.delayedCall(250, () => this.ensureScenarioCombatAssets()); @@ -4010,6 +4117,7 @@ export class BattleScene extends Phaser.Scene { private ensureScenarioMapTexture(onReady: () => void) { const textureKey = battleScenario.mapTextureKey; + this.releaseUnusedBattleMapTextures(textureKey); if (this.textures.exists(textureKey)) { onReady(); return; @@ -4127,7 +4235,11 @@ export class BattleScene extends Phaser.Scene { if (!this.debugToolsEnabled() || typeof window === 'undefined') { return fallback; } - const requested = Number(new URLSearchParams(window.location.search).get('debugCombatAssetWatchdogMs')); + const requestedValue = new URLSearchParams(window.location.search).get('debugCombatAssetWatchdogMs'); + if (requestedValue === null || requestedValue.trim() === '') { + return fallback; + } + const requested = Number(requestedValue); return Number.isFinite(requested) ? Phaser.Math.Clamp(Math.round(requested), 250, fallback) : fallback; @@ -4172,8 +4284,9 @@ export class BattleScene extends Phaser.Scene { let settled = false; let pollTimer: ReturnType | undefined; let watchdogTimer: ReturnType | undefined; + let ownedLoaderFiles: Phaser.Loader.File[] = []; const handleComplete = () => settle(); - const handleShutdown = () => settle(false); + const handleShutdown = () => settle(false, true); const cleanup = () => { this.load.off('complete', handleComplete); this.load.off('loaderror', handleLoadError); @@ -4187,12 +4300,15 @@ export class BattleScene extends Phaser.Scene { watchdogTimer = undefined; } }; - const settle = (notify = true) => { + const settle = (notify = true, cancelPending = false) => { if (settled) { return; } settled = true; cleanup(); + if (cancelPending) { + cancelLoaderFiles(this, ownedLoaderFiles, notify); + } if (loadingText.active) { loadingText.destroy(); } @@ -4213,11 +4329,23 @@ export class BattleScene extends Phaser.Scene { settle(); } }, 50); - watchdogTimer = setTimeout(() => settle(), watchdogMs); + watchdogTimer = setTimeout(() => settle(true, true), watchdogMs); missingEntries.forEach((entry) => this.load.image(entry.textureKey, entry.url)); + ownedLoaderFiles = captureLoaderFiles(this, missingEntries.map((entry) => entry.textureKey)); this.load.start(); } + private releaseUnusedBattleMapTextures(keepTextureKey: string) { + this.textures.getTextureKeys().forEach((textureKey) => { + if ( + textureKey !== keepTextureKey && + Object.prototype.hasOwnProperty.call(battleMapAssets, textureKey) + ) { + releaseTextureSource(this, textureKey); + } + }); + } + private debugSpritePreviewKeys() { const key = this.debugSpritePreviewKey(); return key ? [key] : []; @@ -6865,11 +6993,22 @@ export class BattleScene extends Phaser.Scene { const moveAllowance = this.movementAllowance(unit); const remaining = Math.max(0, moveAllowance - spent); const terrainHint = this.terrainEffectText(terrain, unit, 'move'); - const text = + const moveText = spent === 0 ? `${unit.name}: 제자리 명령 · ${terrainHint}` : `${unit.name}: 이동 가능 · 소모 ${spent}/${moveAllowance} · 남은 ${remaining} · ${terrainHint}`; - this.showPointerFeedback(tile, palette.blue, text, key, 0x1c7ed6, 0.12, 0.9); + const intentRisk = this.moveIntentRiskForTile(unit, tile.x, tile.y); + const tone = this.moveIntentRiskTone(intentRisk); + const fillColor = intentRisk.tone === 'safer' ? 0x2f8f67 : intentRisk.tone === 'riskier' ? 0xb64a45 : 0x1c7ed6; + this.showPointerFeedback( + tile, + tone, + `${moveText}\n${this.moveIntentRiskLine(intentRisk)}`, + key, + fillColor, + 0.14, + 0.94 + ); return; } @@ -6911,13 +7050,14 @@ export class BattleScene extends Phaser.Scene { if (target && canTarget) { const preview = this.combatPreview(unit, target, action, usable); + const exchange = this.combatExchangePreview(preview); const bondChainReady = this.bondChainReadyPreviewData(preview); const locked = this.isLockedDamageTarget(unit, target, action, usable); - const counter = preview.counterAvailable ? '반격 주의' : '반격 없음'; const terrain = `${preview.terrainLabel} ${this.combatTerrainSummary(preview)}`; - const feedbackText = bondChainReady + const attackText = bondChainReady ? `${bondChainReady.text} · ${target.name} 피해 ${preview.damage} · ${this.previewSuccessLabel(preview)} ${preview.hitRate}%` - : `${target.name}: 피해 ${preview.damage} · ${this.previewSuccessLabel(preview)} ${preview.hitRate}% · ${counter} · ${terrain}`; + : `${target.name}: 피해 ${preview.damage} · ${this.previewSuccessLabel(preview)} ${preview.hitRate}% · ${terrain}`; + const feedbackText = `${attackText}\n${this.combatExchangeCounterLine(exchange)}`; this.showPointerFeedback( tile, this.previewAccentColor(preview), @@ -7015,7 +7155,8 @@ export class BattleScene extends Phaser.Scene { private renderPointerFeedbackHint(text: string, tone: number) { const left = this.layout.panelX + this.battleUiLength(18); const width = this.layout.panelWidth - this.battleUiLength(36); - const height = this.battleUiLength(32); + const multiline = text.includes('\n'); + const height = this.battleUiLength(multiline ? 50 : 32); const quickTabsVisible = this.sideQuickTabLayout?.visible === true; const top = quickTabsVisible ? Math.max(this.sidePanelBodyTop(), this.sideContentBottom(6) - height) @@ -7031,7 +7172,9 @@ export class BattleScene extends Phaser.Scene { fontSize: this.battleUiFontSize(12), color: '#f2e3bf', fontStyle: '700', - fixedWidth: width - this.battleUiLength(24) + fixedWidth: width - this.battleUiLength(24), + lineSpacing: this.battleUiLength(2), + maxLines: 2 }); } @@ -7042,7 +7185,7 @@ export class BattleScene extends Phaser.Scene { this.pointerFeedbackHintBg.setVisible(true); this.pointerFeedbackHintText.setDepth(backgroundDepth + 1); this.pointerFeedbackHintText.setPosition(left + this.battleUiLength(12), top + this.battleUiLength(8)); - this.pointerFeedbackHintText.setText(this.truncateUiText(text, 38)); + this.pointerFeedbackHintText.setText(this.truncateUiText(text, multiline ? 100 : 48)); this.pointerFeedbackHintText.setVisible(true); } @@ -7054,6 +7197,138 @@ export class BattleScene extends Phaser.Scene { this.lastPointerFeedbackKey = undefined; } + private handleBattleKeyboardNavigation(event: KeyboardEvent) { + if ( + (this.phase !== 'moving' && this.phase !== 'targeting') || + this.commandMenuObjects.length > 0 || + this.mapMenuObjects.length > 0 || + this.tacticalInitiativePanelObjects.length > 0 || + this.saveSlotPanelObjects.length > 0 || + this.turnPromptObjects.length > 0 || + this.combatCutInObjects.length > 0 || + this.resultObjects.length > 0 + ) { + return false; + } + + const direction = event.code === 'ArrowLeft' + ? { x: -1, y: 0 } + : event.code === 'ArrowRight' + ? { x: 1, y: 0 } + : event.code === 'ArrowUp' + ? { x: 0, y: -1 } + : event.code === 'ArrowDown' + ? { x: 0, y: 1 } + : undefined; + const cycle = event.code === 'Tab'; + if (!direction && !cycle) { + return false; + } + + event.preventDefault(); + if (this.phase === 'moving' && this.selectedUnit) { + const tiles = this.reachableTiles(this.selectedUnit).map(({ x, y }) => ({ x, y })); + const tile = this.nextKeyboardBattleCursor(tiles, 'move', direction, event.shiftKey ? -1 : 1); + if (tile) { + this.keyboardBattleCursor = { mode: 'move', x: tile.x, y: tile.y }; + this.focusKeyboardBattleCursor(tile.x, tile.y); + this.updateMovePointerFeedback(this.selectedUnit, tile, true); + soundDirector.playSelect(); + } + return true; + } + + if (this.phase === 'targeting' && this.selectedUnit && this.targetingAction) { + const usable = this.selectedUsable; + const support = Boolean(usable && usable.effect !== 'damage'); + const targets = support && usable + ? this.supportTargets(this.selectedUnit, usable) + : this.damageableTargets(this.selectedUnit, this.targetingAction, usable); + const mode: BattleKeyboardCursorMode = support ? 'support' : 'damage'; + const target = this.nextKeyboardBattleCursor(targets, mode, direction, event.shiftKey ? -1 : 1); + if (target) { + this.clearLockedTargetPreview(); + this.keyboardBattleCursor = { mode, x: target.x, y: target.y, targetId: target.id }; + this.focusKeyboardBattleCursor(target.x, target.y); + this.updateTargetingPointerFeedback(this.selectedUnit, target, true); + soundDirector.playSelect(); + } + return true; + } + + return false; + } + + private nextKeyboardBattleCursor( + candidates: T[], + mode: BattleKeyboardCursorMode, + direction?: { x: number; y: number }, + cycleStep = 1 + ) { + if (candidates.length === 0) { + return undefined; + } + + const ordered = [...candidates].sort((left, right) => left.y - right.y || left.x - right.x); + const current = this.keyboardBattleCursor?.mode === mode + ? this.keyboardBattleCursor + : this.selectedUnit + ? { x: this.selectedUnit.x, y: this.selectedUnit.y } + : ordered[0]; + if (!direction) { + const currentIndex = ordered.findIndex((candidate) => candidate.x === current.x && candidate.y === current.y); + const start = currentIndex >= 0 ? currentIndex : cycleStep > 0 ? -1 : 0; + return ordered[(start + cycleStep + ordered.length) % ordered.length]; + } + + const directional = ordered + .map((candidate) => { + const dx = candidate.x - current.x; + const dy = candidate.y - current.y; + const forward = dx * direction.x + dy * direction.y; + const perpendicular = Math.abs(dx * direction.y - dy * direction.x); + return { candidate, forward, perpendicular }; + }) + .filter(({ forward }) => forward > 0) + .sort((left, right) => left.perpendicular - right.perpendicular || left.forward - right.forward); + return directional[0]?.candidate; + } + + private focusKeyboardBattleCursor(x: number, y: number) { + if (!this.isTileVisible(x, y)) { + this.centerCameraOnTile(x, y); + } + } + + private activateKeyboardBattleCursor() { + const cursor = this.keyboardBattleCursor; + if (!cursor || !this.selectedUnit) { + return false; + } + + if (cursor.mode === 'move' && this.phase === 'moving') { + this.moveSelectedUnit(cursor.x, cursor.y); + return true; + } + if (this.phase !== 'targeting' || !cursor.targetId || !this.targetingAction) { + return false; + } + + const target = battleUnits.find((candidate) => candidate.id === cursor.targetId && candidate.hp > 0); + if (!target) { + return false; + } + if (cursor.mode === 'support' && this.selectedUsable) { + this.chooseSupportTarget(this.selectedUnit, target, this.selectedUsable); + return true; + } + if (cursor.mode === 'damage') { + this.chooseDamageTarget(this.selectedUnit, target, this.targetingAction, this.selectedUsable); + return true; + } + return false; + } + private unitAtTile(x: number, y: number, exceptUnitId?: string) { return battleUnits.find((unit) => unit.id !== exceptUnitId && unit.hp > 0 && unit.x === x && unit.y === y); } @@ -7179,7 +7454,7 @@ export class BattleScene extends Phaser.Scene { this.showMoveRange(unit); this.updateMiniMap(); this.refreshUnitLegibilityStyles(); - this.renderUnitDetail(unit, '이동할 파란 칸을 선택하세요.'); + this.renderUnitDetail(unit, '이동할 파란 칸을 선택하세요.\n키보드: 방향키/Tab 탐색 · Enter 선택'); if (completingTutorialSelect) { this.suppressNextLeftClick = true; this.setFirstBattleTutorialStep('move'); @@ -7187,6 +7462,8 @@ export class BattleScene extends Phaser.Scene { } private showMoveRange(unit: UnitData) { + this.moveIntentPreviewCache.clear(); + this.lastMoveIntentRisk = undefined; const moveAllowance = this.movementAllowance(unit); this.reachableTiles(unit).forEach(({ x, y, cost }) => { const remaining = Math.max(0, moveAllowance - cost); @@ -7210,7 +7487,10 @@ export class BattleScene extends Phaser.Scene { } marker.setVisible(this.isTileVisible(x, y)); marker.setInteractive({ useHandCursor: true }); - marker.on('pointerover', () => marker.setFillStyle(palette.blue, Math.min(fillAlpha + 0.12, 0.5))); + marker.on('pointerover', () => { + marker.setFillStyle(palette.blue, Math.min(fillAlpha + 0.12, 0.5)); + this.updateMovePointerFeedback(unit, { x, y }, true); + }); marker.on('pointerout', () => marker.setFillStyle(palette.blue, fillAlpha)); marker.on('pointerdown', (pointer: Phaser.Input.Pointer) => { if (pointer.leftButtonDown()) { @@ -7246,15 +7526,25 @@ export class BattleScene extends Phaser.Scene { const fromX = unit.x; const fromY = unit.y; const intentBefore = this.enemyIntentSnapshots(); + this.moveIntentRiskForTile(unit, x, y); const stayingInPlace = fromX === x && fromY === y; unit.x = x; unit.y = y; - this.pendingMove = { unit, fromX, fromY, toX: x, toY: y, intentBefore, intentAfterMove: [] }; this.phase = 'command'; this.clearMarkers(); this.updateMiniMap(); this.refreshEnemyIntentForecast(); - this.pendingMove.intentAfterMove = this.enemyIntentSnapshots(); + const intentAfterMove = this.enemyIntentSnapshots(); + this.pendingMove = { + unit, + fromX, + fromY, + toX: x, + toY: y, + intentBefore, + intentAfterMove, + intentRisk: compareMoveIntentRisk(intentBefore, intentAfterMove, unit.id, unit.hp) + }; soundDirector.playSelect(); const targetX = this.tileCenterX(x); @@ -7287,7 +7577,9 @@ export class BattleScene extends Phaser.Scene { this.showTurnEndPrompt(undefined, { mode: 'post-move', title: '마지막 장수 이동 완료', - body: this.enemyIntentTurnEndBody(), + body: this.pendingMove?.unit.id === unit.id + ? `이동 결과 · ${this.moveIntentRiskCompactLine(this.pendingMove.intentRisk)}\n${this.enemyIntentTurnEndBody()}` + : this.enemyIntentTurnEndBody(), primaryLabel: '대기 후 턴 종료', secondaryLabel: '명령 선택', primaryAction: () => { @@ -7525,6 +7817,7 @@ export class BattleScene extends Phaser.Scene { this.bondChainReadyMarkerViews = []; this.bondChainReadyPartnerMarkers.clear(); this.lockedTargetPreview = undefined; + this.keyboardBattleCursor = undefined; this.clearPointerFeedback(); } @@ -8814,10 +9107,23 @@ export class BattleScene extends Phaser.Scene { usableId: plan.usable?.id ?? null, damage: plan.preview?.damage ?? 0, hitRate: plan.preview?.hitRate ?? 0, + criticalRate: plan.preview?.criticalRate ?? 0, sortiePreventedDamage: plan.sortiePreventedDamage ?? 0 }; } + private enemyIntentSnapshotsFromPlans(plans: readonly EnemyActionPlan[]): EnemyIntentSnapshot[] { + return plans.map((plan) => ({ + enemyId: plan.enemy.id, + kind: plan.kind, + targetId: plan.target?.id, + usableId: plan.usable?.id, + damage: plan.preview?.damage ?? 0, + hitRate: plan.preview?.hitRate ?? 0, + criticalRate: plan.preview?.criticalRate ?? 0 + })); + } + private enemyIntentSnapshots() { if (!this.enemyIntentForecastEnabled()) { return []; @@ -8825,14 +9131,75 @@ export class BattleScene extends Phaser.Scene { const plans = this.enemyIntentForecasts.size > 0 ? Array.from(this.enemyIntentForecasts.values()) : this.currentEnemyActionPlans(); - return plans.map((plan) => ({ - enemyId: plan.enemy.id, - kind: plan.kind, - targetId: plan.target?.id, - usableId: plan.usable?.id, - damage: plan.preview?.damage ?? 0, - hitRate: plan.preview?.hitRate ?? 0 - })); + return this.enemyIntentSnapshotsFromPlans(plans); + } + + private enemyIntentSnapshotsForMove(unit: UnitData, x: number, y: number) { + const key = `${this.turnNumber}:${unit.id}:${unit.x},${unit.y}>${x},${y}`; + const cached = this.moveIntentPreviewCache.get(key); + if (cached) { + return cached; + } + + const previousX = unit.x; + const previousY = unit.y; + unit.x = x; + unit.y = y; + try { + const snapshots = this.enemyIntentSnapshotsFromPlans(this.currentEnemyActionPlans()); + this.moveIntentPreviewCache.set(key, snapshots); + return snapshots; + } finally { + unit.x = previousX; + unit.y = previousY; + } + } + + private moveIntentRiskForTile(unit: UnitData, x: number, y: number) { + const before = this.enemyIntentSnapshots(); + const after = x === unit.x && y === unit.y ? before : this.enemyIntentSnapshotsForMove(unit, x, y); + const risk = compareMoveIntentRisk(before, after, unit.id, unit.hp); + this.lastMoveIntentRisk = risk; + return risk; + } + + private moveIntentRiskTone(risk: MoveIntentRiskDelta) { + if (risk.tone === 'safer') { + return 0x59d18c; + } + if (risk.tone === 'riskier') { + return 0xd8732c; + } + return 0x83d6ff; + } + + private moveIntentRiskLine(risk: MoveIntentRiskDelta) { + const direction = risk.tone === 'safer' ? '↓ 개선' : risk.tone === 'riskier' ? '↑ 위험' : '= 유지'; + const personal = risk.after.immediateCount > 0 || risk.before.immediateCount > 0 + ? `본인 공격 ${risk.before.immediateCount}→${risk.after.immediateCount} · 기대피해 ${risk.before.expectedDamage}→${risk.after.expectedDamage}` + : `본인 즉시공격 없음${risk.after.approachCount > 0 ? ` · 접근 ${risk.after.approachCount}` : ''}`; + const team = risk.teamExpectedDamageDelta === 0 + ? `전군 ${risk.teamExpectedDamageAfter}` + : `전군 ${risk.teamExpectedDamageBefore}→${risk.teamExpectedDamageAfter}`; + const retarget = risk.retargetedToCount > 0 + ? ` · 표적 유입 +${risk.retargetedToCount}` + : risk.retargetedAwayCount > 0 + ? ` · 표적 이탈 ${risk.retargetedAwayCount}` + : risk.neutralizedImmediateCount > 0 + ? ` · 공격 차단 ${risk.neutralizedImmediateCount}` + : ''; + return `적 의도 ${direction} · ${personal} · ${team}${retarget}`; + } + + private moveIntentRiskCompactLine(risk: MoveIntentRiskDelta) { + const direction = risk.tone === 'safer' ? '안전 개선' : risk.tone === 'riskier' ? '위험 증가' : '위험 유지'; + const target = risk.after.immediateCount > 0 + ? `본인 ${risk.after.immediateCount}명·기대 ${risk.after.expectedDamage}` + : `본인 즉시공격 없음${risk.after.approachCount > 0 ? `·접근 ${risk.after.approachCount}` : ''}`; + const delta = risk.teamExpectedDamageDelta === 0 + ? `전군 ${risk.teamExpectedDamageAfter}` + : `전군 ${risk.teamExpectedDamageDelta > 0 ? '+' : ''}${risk.teamExpectedDamageDelta}`; + return `${direction} · ${target} · ${delta}`; } private settleEnemyIntentCounterplay( @@ -9099,10 +9466,11 @@ export class BattleScene extends Phaser.Scene { } const preview = this.combatPreview(unit, target, 'attack'); + const exchange = this.combatExchangePreview(preview); return { available: true, status: `대상 ${targets.length}`, - detail: `${target.name} · 피해 ${preview.damage} · ${preview.hitRate}% · ${preview.counterAvailable ? '반격 주의' : '반격 없음'}` + detail: `${target.name} · 피해 ${preview.damage}/${preview.hitRate}% · ${this.combatExchangeCompactLabel(exchange)}` }; } @@ -9141,7 +9509,8 @@ export class BattleScene extends Phaser.Scene { const commands: BattleCommand[] = ['attack', 'strategy', 'item', 'wait']; const menuWidth = 330; - const titleHeight = 54; + const moveRisk = this.pendingMove?.unit.id === unit.id ? this.pendingMove.intentRisk : undefined; + const titleHeight = moveRisk ? 74 : 54; const buttonHeight = 70; const gap = 8; const padding = 10; @@ -9172,6 +9541,18 @@ export class BattleScene extends Phaser.Scene { subtitle.setDepth(15); this.commandMenuObjects.push(subtitle); + if (moveRisk) { + const riskText = this.add.text(left + 16, top + padding + 49, this.truncateUiText(this.moveIntentRiskCompactLine(moveRisk), 43), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '11px', + color: moveRisk.tone === 'safer' ? '#a8ffd0' : moveRisk.tone === 'riskier' ? '#ffb08a' : '#b9d9ec', + fontStyle: '700', + fixedWidth: menuWidth - 32 + }); + riskText.setDepth(15); + this.commandMenuObjects.push(riskText); + } + commands.forEach((command, index) => { const buttonTop = top + padding + titleHeight + index * (buttonHeight + gap); const accent = this.commandAccentColor(command); @@ -10150,14 +10531,19 @@ export class BattleScene extends Phaser.Scene { const orderedTargets = targets .map((target) => { const preview = this.combatPreview(unit, target, action, usable); - return { target, preview, bondChainReady: this.bondChainReadyPreviewData(preview) }; + return { + target, + preview, + exchange: this.combatExchangePreview(preview), + bondChainReady: this.bondChainReadyPreviewData(preview) + }; }) .sort((left, right) => Number(Boolean(right.bondChainReady)) - Number(Boolean(left.bondChainReady))); const hasBondChainReady = orderedTargets.some(({ bondChainReady }) => Boolean(bondChainReady)); - const candidates = orderedTargets.slice(0, 3).map(({ target, preview, bondChainReady }): TargetingGuideCandidate => { + const candidates = orderedTargets.slice(0, 3).map(({ target, preview, exchange, bondChainReady }): TargetingGuideCandidate => { const distance = this.tileDistance(unit, target); const successLabel = this.previewSuccessLabel(preview); - const counterLabel = preview.counterAvailable ? '반격 주의' : '반격 없음'; + const counterLabel = this.combatExchangeCompactLabel(exchange); const pendingBonusLabels = this.pendingEndBattleBonusObjectiveLabels(preview); const endsBattleWithPendingBonus = pendingBonusLabels.length > 0; const warningText = endsBattleWithPendingBonus ? this.previewObjectiveWarningSummary(pendingBonusLabels) : undefined; @@ -10180,7 +10566,12 @@ export class BattleScene extends Phaser.Scene { ? { icon: 'failure', label: '보조', value: '미확보', tone: 0xd8732c } : bondChainReady ? { icon: 'leadership', label: bondChainReady.coreResonance ? '핵심' : '추격', value: `${bondChainReady.rate}% · +${bondChainReady.damage}`, tone: palette.gold } - : { icon: 'counter', label: '반격', value: preview.counterAvailable ? '주의' : '없음', tone: preview.counterAvailable ? 0xd8732c : 0x59d18c } + : { + icon: 'counter', + label: '반격 피해·명중·치명', + value: this.combatExchangeCounterMetric(exchange), + tone: exchange.projection.counterLethalOnHit || exchange.projection.counterLethalOnCritical ? 0xc93b30 : exchange.counter ? 0xd8732c : 0x59d18c + } ] }; }); @@ -10202,7 +10593,7 @@ export class BattleScene extends Phaser.Scene { ? orderedTargets.some(({ bondChainReady }) => bondChainReady?.coreResonance) ? '핵심 공명 우선 · 명중 후 생존 시 확률 판정' : '금색 공명 · 명중 후 생존 시 확률 판정' - : '표시된 적 선택 · 확정 전 예측 확인', + : '마우스 또는 방향키/Tab · Enter 선택/확정', notice: notice ?? objectiveWarningNotice }); } @@ -10257,7 +10648,7 @@ export class BattleScene extends Phaser.Scene { ], candidates, overflowCount: Math.max(0, targets.length - candidates.length), - footer: '표시된 대상 선택 · 확정 전 예측 확인', + footer: '마우스 또는 방향키/Tab · Enter 선택/확정', notice }); } @@ -11397,6 +11788,55 @@ export class BattleScene extends Phaser.Scene { return preview.usable ? this.usablePowerIcon(preview.usable) : 'attack'; } + private combatExchangePreview(preview: CombatPreview): CombatExchangePreview { + const counter = preview.counterAvailable + ? this.combatPreview(preview.defender, preview.attacker, 'attack', undefined, true) + : undefined; + const projection = buildCombatExchangeProjection({ + attackerHp: preview.attacker.hp, + defenderHp: preview.defender.hp, + attackDamage: preview.damage, + attackHitRate: preview.hitRate, + attackCriticalRate: preview.criticalRate, + counter: counter + ? { + damage: counter.damage, + hitRate: counter.hitRate, + criticalRate: counter.criticalRate + } + : undefined + }); + const exchange = { attack: preview, counter, projection }; + this.lastCombatExchangePreview = exchange; + return exchange; + } + + private combatExchangeCounterLine(exchange: CombatExchangePreview) { + const { attack, counter, projection } = exchange; + if (!counter) { + return `왕복 예측 · 적 HP ${attack.defender.hp}→${projection.defenderHpAfterHit} · 반격 없음`; + } + const condition = projection.counterCondition === 'on-primary-miss' ? '공격 빗나가면' : '대상 생존 시'; + const criticalHp = counter.criticalRate > 0 ? ` (치명 ${projection.attackerHpAfterCounterCriticalHit})` : ''; + const danger = projection.counterLethalOnHit || projection.counterLethalOnCritical ? ' · 아군 격파 위험' : ''; + return `왕복 예측 · ${condition} 반격 ${counter.damage}/${counter.hitRate}%·치명 ${counter.criticalRate}% · 아군 HP ${attack.attacker.hp}→${projection.attackerHpAfterCounterHit}${criticalHp}${danger}`; + } + + private combatExchangeCounterMetric(exchange: CombatExchangePreview) { + if (!exchange.counter) { + return '없음'; + } + return `${exchange.counter.damage}·${exchange.counter.hitRate}%·치${exchange.counter.criticalRate}%`; + } + + private combatExchangeCompactLabel(exchange: CombatExchangePreview) { + if (!exchange.counter) { + return '반격 없음'; + } + const condition = exchange.projection.counterCondition === 'on-primary-miss' ? '빗나가면' : '생존 시'; + return `${condition} 반격 ${exchange.counter.damage}/${exchange.counter.hitRate}%·치${exchange.counter.criticalRate}%`; + } + private pendingEndBattleBonusObjectiveLabels(preview: CombatPreview) { if (preview.defender.id !== leaderUnitId || preview.damage < preview.defender.hp) { return []; @@ -11487,11 +11927,12 @@ export class BattleScene extends Phaser.Scene { const attackerWeapon = getItem(preview.attacker.equipment.weapon.itemId); const defenderArmor = getItem(preview.defender.equipment.armor.itemId); const isStrategy = preview.action === 'strategy'; + const exchange = this.combatExchangePreview(preview); const sourceIcon = preview.usable ? this.usableIcon(preview.usable) : this.itemIcon(attackerWeapon, 'weapon'); const sourceName = preview.usable?.name ?? attackerWeapon.name; const sourceLabel = preview.usable ? this.usableChannelLabel(preview.usable) : isStrategy ? '책략 장비' : '공격 장비'; const defenseLabel = isStrategy ? '대상 방어' : '대상 방어구'; - const counterTone = preview.counterAvailable ? 0xb64a45 : 0x59d18c; + const counterTone = exchange.projection.counterLethalOnHit || exchange.projection.counterLethalOnCritical ? 0xc93b30 : exchange.counter ? 0xb64a45 : 0x59d18c; const bg = this.trackSideObject(this.add.rectangle(left, y, width, height, 0x121a22, 1)); bg.setOrigin(0); @@ -11540,8 +11981,8 @@ export class BattleScene extends Phaser.Scene { metricTop, metricWidth, 'counter', - '반격', - preview.counterAvailable ? '주의' : '없음', + exchange.counter ? '반격 피해·명중·치명' : '반격', + this.combatExchangeCounterMetric(exchange), counterTone ); @@ -11550,7 +11991,7 @@ export class BattleScene extends Phaser.Scene { this.renderPreviewEquipmentChip(left + 10, y + 214, chipWidth, sourceIcon, sourceLabel, sourceName); this.renderPreviewEquipmentChip(left + 10 + chipWidth + chipGap, y + 214, chipWidth, this.itemIcon(defenderArmor, 'armor'), defenseLabel, defenderArmor.name); - this.renderPreviewForecastRow(preview, left + 10, y + 272, width - 20, accent); + this.renderPreviewForecastRow(exchange, left + 10, y + 272, width - 20, accent); this.renderPreviewModifierCards(left + 10, y + 312, width - 20, this.previewModifierSummaries(preview), accent); const showsBondChainReady = this.renderBondChainReadyPreviewBanner(preview, left + 10, y + 372, width - 20, locked); @@ -11982,9 +12423,9 @@ export class BattleScene extends Phaser.Scene { valueText.setOrigin(1, 0.5); } - private renderPreviewForecastRow(preview: CombatPreview, x: number, y: number, width: number, accent: number) { - const projectedHp = Math.max(0, preview.defender.hp - preview.damage); - const label = preview.action === 'strategy' ? '성공 시 HP' : '명중 시 HP'; + private renderPreviewForecastRow(exchange: CombatExchangePreview, x: number, y: number, width: number, accent: number) { + const { attack: preview, counter, projection } = exchange; + const label = counter ? '왕복 교전 HP' : preview.action === 'strategy' ? '성공 시 HP' : '명중 시 HP'; const row = this.trackSideObject(this.add.rectangle(x, y, width, 30, 0x0b1118, 0.94)); row.setOrigin(0); row.setStrokeStyle(1, accent, 0.48); @@ -11995,11 +12436,16 @@ export class BattleScene extends Phaser.Scene { color: '#9fb0bf', fontStyle: '700' })); - this.drawGauge(x + 112, y + 11, width - 190, 10, projectedHp / preview.defender.maxHp, projectedHp <= 0 ? 0xb64a45 : 0x59d18c); - const hpText = this.trackSideObject(this.add.text(x + width - 8, y + 8, `${preview.defender.hp} → ${projectedHp}`, { + const counterPrefix = projection.counterCondition === 'on-primary-miss' ? '빗나가면 ' : ''; + const defenderCriticalHp = preview.criticalRate > 0 ? `(치 ${projection.defenderHpAfterCriticalHit})` : ''; + const counterCriticalHp = counter && counter.criticalRate > 0 ? `(치 ${projection.attackerHpAfterCounterCriticalHit})` : ''; + const hpSummary = counter + ? `적 ${preview.defender.hp}→${projection.defenderHpAfterHit}${defenderCriticalHp} · ${counterPrefix}아군 ${preview.attacker.hp}→${projection.attackerHpAfterCounterHit}${counterCriticalHp}` + : `적 ${preview.defender.hp}→${projection.defenderHpAfterHit}${defenderCriticalHp} · 아군 ${preview.attacker.hp} 유지`; + const hpText = this.trackSideObject(this.add.text(x + width - 8, y + 8, hpSummary, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '13px', - color: projectedHp <= 0 ? '#ff8f5f' : '#f2e3bf', + fontSize: '12px', + color: projection.counterLethalOnHit || projection.counterLethalOnCritical ? '#ff8f5f' : projection.defenderHpAfterHit <= 0 || projection.attackLethalOnCritical ? '#ffd28a' : '#f2e3bf', fontStyle: '700' })); hpText.setOrigin(1, 0); @@ -12451,9 +12897,6 @@ export class BattleScene extends Phaser.Scene { } private resultContinueRoute(): { destination: ResultContinueDestination; label: string } { - if (battleScenario.id === 'sixty-sixth-battle-wuzhang-final') { - return { destination: 'epilogue', label: '에필로그로' }; - } if (battleScenario.id === 'first-battle-zhuo-commandery') { return { destination: 'victory-story', label: '승리 이야기로' }; } @@ -12478,14 +12921,6 @@ export class BattleScene extends Phaser.Scene { } const { destination } = this.resultContinueRoute(); - if (destination === 'epilogue') { - markCampaignStep('ending-complete'); - void startLazyScene(this, 'StoryScene', { - pages: [...battleScenario.victoryPages, ...endingEpiloguePages], - nextScene: 'EndingScene' - }); - return; - } if (destination === 'victory-story') { void startLazyScene(this, 'StoryScene', { pages: firstBattleVictoryPages, @@ -12831,6 +13266,9 @@ export class BattleScene extends Phaser.Scene { completedCampVisits: [], createdAt: new Date().toISOString() }); + if (outcome === 'victory') { + clearBattleSaveStorageForBattle(battleScenario.id, getCampaignState().activeSaveSlot); + } this.resultSortieOrder = persistedReport.sortieOrder ?? sortieOrder; } @@ -17496,7 +17934,7 @@ export class BattleScene extends Phaser.Scene { secondaryLabel: '취소', primaryAction: () => { this.hideTurnEndPrompt(); - this.loadBattleState(slot); + this.restartBattleFromSave(slot); }, secondaryAction: () => { this.hideTurnEndPrompt(); @@ -17509,37 +17947,59 @@ export class BattleScene extends Phaser.Scene { return `${battleSaveStorageKey}:slot-${normalizeBattleSaveSlot(slot, campaignSaveSlotCount)}`; } - private readBattleSaveState(slot: number) { + private readBattleSaveState(slot: number, validateCurrentComposition = false) { const normalizedSlot = normalizeBattleSaveSlot(slot, campaignSaveSlotCount); - const raw = - window.localStorage.getItem(this.battleSaveStorageKeyForSlot(normalizedSlot)) ?? - (normalizedSlot === 1 ? window.localStorage.getItem(battleSaveStorageKey) ?? window.localStorage.getItem(legacyBattleSaveStorageKey) : undefined); const savedCampaign = readCampaignSaveState(normalizedSlot); - return parseBattleSaveState(raw, { - expectedBattleId: battleScenario.id, - allowTacticalCommand: Boolean(battleScenario.sortie), - allowLegacyTacticalCommand: this.isFirstPursuitRoleEffectBattle(), - mapWidth: battleMap.width, - mapHeight: battleMap.height, - validUnitIds: new Set(battleUnits.map((unit) => unit.id)), - validAllyUnitIds: new Set(battleUnits.filter((unit) => unit.faction === 'ally').map((unit) => unit.id)), - validEnemyUnitIds: new Set(battleUnits.filter((unit) => unit.faction === 'enemy').map((unit) => unit.id)), - validTacticalCommandRolesByActor: this.tacticalInitiativeActorRolesForCampaign(savedCampaign), - validUsableIds: new Set(Object.keys(usableCatalog)), - validItemIds: new Set( - Object.values(usableCatalog) - .filter((usable) => usable.command === 'item') - .map((usable) => usable.id) - ), - validEquipmentItemIds: new Set(Object.keys(itemCatalog)), - validEquipmentSlotItems: Object.fromEntries( - equipmentSlots.map((slot) => [ - slot, - new Set(Object.values(itemCatalog).filter((item) => item.slot === slot).map((item) => item.id)) - ]) - ), - validTriggeredBattleEventIds: this.validTriggeredBattleEventIds() - }); + const currentCompositionValidation = validateCurrentComposition + ? { + validUnitIds: new Set(battleUnits.map((unit) => unit.id)), + validAllyUnitIds: new Set(battleUnits.filter((unit) => unit.faction === 'ally').map((unit) => unit.id)), + validEnemyUnitIds: new Set(battleUnits.filter((unit) => unit.faction === 'enemy').map((unit) => unit.id)) + } + : {}; + const candidate = readBattleSaveStorageCandidate( + battleScenario.id, + savedCampaign?.step, + normalizedSlot, + window.localStorage, + campaignSaveSlotCount, + { + allowTacticalCommand: Boolean(battleScenario.sortie), + allowLegacyTacticalCommand: this.isFirstPursuitRoleEffectBattle(), + mapWidth: battleMap.width, + mapHeight: battleMap.height, + ...currentCompositionValidation, + validTacticalCommandRolesByActor: this.tacticalInitiativeActorRolesForCampaign(savedCampaign), + validUsableIds: new Set(Object.keys(usableCatalog)), + validItemIds: new Set( + Object.values(usableCatalog) + .filter((usable) => usable.command === 'item') + .map((usable) => usable.id) + ), + validEquipmentItemIds: new Set(Object.keys(itemCatalog)), + validEquipmentSlotItems: Object.fromEntries( + equipmentSlots.map((slot) => [ + slot, + new Set(Object.values(itemCatalog).filter((item) => item.slot === slot).map((item) => item.id)) + ]) + ), + validTriggeredBattleEventIds: this.validTriggeredBattleEventIds() + } + ); + return candidate?.state; + } + + private restartBattleFromSave(slot: number) { + const normalizedSlot = normalizeBattleSaveSlot(slot, campaignSaveSlotCount); + if (!this.readBattleSaveState(normalizedSlot)) { + this.renderSituationPanel('불러올 저장 데이터가 없습니다.'); + return; + } + loadCampaignState(normalizedSlot); + this.scene.restart({ + battleId: battleScenario.id, + resumeBattleSaveSlot: normalizedSlot + } satisfies BattleSceneData); } private validTriggeredBattleEventIds() { @@ -17556,20 +18016,22 @@ export class BattleScene extends Phaser.Scene { private saveBattleState(slot = 1) { try { const state = this.createBattleSaveState(); - window.localStorage.setItem(this.battleSaveStorageKeyForSlot(slot), JSON.stringify(state)); - if (slot === 1) { + const normalizedSlot = normalizeBattleSaveSlot(slot, campaignSaveSlotCount); + clearCampaignBattleSavesForSlot(normalizedSlot); + window.localStorage.setItem(this.battleSaveStorageKeyForSlot(normalizedSlot), JSON.stringify(state)); + if (normalizedSlot === 1) { window.localStorage.setItem(battleSaveStorageKey, JSON.stringify(state)); window.localStorage.removeItem(legacyBattleSaveStorageKey); } - saveCampaignState(getCampaignState(), slot); - this.renderSituationPanel(`슬롯 ${slot}에 전투 상태를 저장했습니다.\n${this.formatSavedAt(state.savedAt)}`); + saveCampaignState(getCampaignState(), normalizedSlot); + this.renderSituationPanel(`슬롯 ${normalizedSlot}에 전투 상태를 저장했습니다.\n${this.formatSavedAt(state.savedAt)}`); } catch { this.renderSituationPanel('전투 상태를 저장하지 못했습니다.'); } } private loadBattleState(slot = 1) { - const state = this.readBattleSaveState(slot); + const state = this.readBattleSaveState(slot, true); if (!state) { this.renderSituationPanel('불러올 저장 데이터가 없습니다.'); return; @@ -17597,6 +18059,13 @@ export class BattleScene extends Phaser.Scene { this.launchSortieRecommendation = this.normalizeLaunchSortieRecommendation(state.sortieRecommendation); this.applyBattleSaveState(state); this.renderSituationPanel(`슬롯 ${slot}의 전투를 불러왔습니다.\n${this.formatSavedAt(state.savedAt)}`); + if (this.activeFaction === 'enemy' && !this.battleOutcome) { + this.time.delayedCall(250, () => { + if (this.activeFaction === 'enemy' && !this.battleOutcome) { + void this.runEnemyTurn(); + } + }); + } } catch (error) { console.error('[Battle Save] Failed to restore battle state.', error); this.renderSituationPanel('저장 데이터를 불러오지 못했습니다.'); @@ -17632,6 +18101,7 @@ export class BattleScene extends Phaser.Scene { maxHp: unit.maxHp, attack: unit.attack, move: unit.move, + stats: { ...unit.stats }, x: unit.x, y: unit.y, direction: this.unitViews.get(unit.id)?.direction, @@ -17718,12 +18188,18 @@ export class BattleScene extends Phaser.Scene { return; } + const savedLevelUps = Math.max(0, savedUnit.level - unit.level); unit.level = savedUnit.level; unit.exp = savedUnit.exp; unit.hp = Phaser.Math.Clamp(savedUnit.hp, 0, savedUnit.maxHp); unit.maxHp = savedUnit.maxHp; unit.attack = savedUnit.attack; unit.move = savedUnit.move; + if (savedUnit.stats) { + unit.stats = { ...savedUnit.stats }; + } else if (savedLevelUps > 0) { + unit.stats = progressUnitStatsForLevelUps(unit.stats, unit.classKey, savedLevelUps); + } unit.x = Phaser.Math.Clamp(savedUnit.x, 0, battleMap.width - 1); unit.y = Phaser.Math.Clamp(savedUnit.y, 0, battleMap.height - 1); unit.equipment = this.cloneEquipment(savedUnit.equipment); @@ -18920,18 +19396,22 @@ export class BattleScene extends Phaser.Scene { let userFramePromise: Promise = Promise.resolve(); if (userView?.sprite.active && userView.sprite.visible) { + const supportPose: UnitActionPose = result.usable.command === 'strategy' ? 'strategy' : 'item'; + this.setUnitActionFrame(userView.sprite, result.user, userView.direction, supportPose, 0); + const actionScaleX = userView.sprite.scaleX; + const actionScaleY = userView.sprite.scaleY; userFramePromise = this.playUnitActionFrames( userView.sprite, result.user, userView.direction, - result.usable.command === 'strategy' ? 'strategy' : 'item', + supportPose, 42, 1 ); this.tweens.add({ targets: userView.sprite, - scaleX: userView.baseScaleX * 1.08, - scaleY: userView.baseScaleY * 1.08, + scaleX: actionScaleX * 1.08, + scaleY: actionScaleY * 1.08, duration: Math.max(55, Math.round(duration * 0.42)), yoyo: true, ease: 'Sine.easeOut' @@ -22930,10 +23410,9 @@ export class BattleScene extends Phaser.Scene { view.direction = direction; this.stopUnitIdle(view); view.sprite.setFlipX(false); + this.setUnitDirectionFrame(view, direction); const animationKey = `${view.textureBase}-idle-${direction}`; - if (!this.tryPlayUnitAnimation(view, animationKey)) { - this.setUnitDirectionFrame(view, direction); - } + this.tryPlayUnitAnimation(view, animationKey); this.startUnitIdleMotion(unit, view, direction); } @@ -23222,7 +23701,10 @@ export class BattleScene extends Phaser.Scene { } private setUnitActionFrame(sprite: Phaser.GameObjects.Sprite, unit: UnitData, direction: UnitDirection, pose: UnitActionPose, frame = 0) { + const displayWidth = sprite.displayWidth; + const displayHeight = sprite.displayHeight; sprite.setTexture(this.unitActionTexture(unit), this.unitActionFrame(unit, direction, pose, frame)); + sprite.setDisplaySize(displayWidth, displayHeight); this.applyActionSpriteBlend(sprite); } @@ -26672,6 +27154,13 @@ export class BattleScene extends Phaser.Scene { unitTextureKeys: scenarioUnitTextureKeys, missingActionKeys: [...this.scenarioCombatAssetMissingActionKeys], missingPortraitKeys: [...this.scenarioCombatAssetMissingPortraitKeys], + loader: { + state: this.load.state, + loading: this.load.isLoading(), + pending: this.load.list.size, + inflight: this.load.inflight.size, + processing: this.load.queue.size + }, loadDurationMs: this.scenarioCombatAssetLoadStartedAt === undefined ? null @@ -26680,7 +27169,9 @@ export class BattleScene extends Phaser.Scene { battleOutcome: this.battleOutcome ?? null, mapTextureKey: battleScenario.mapTextureKey, mapTextureReady: this.textures.exists(battleScenario.mapTextureKey), - mapBackgroundReady: this.mapBackground?.texture.key === battleScenario.mapTextureKey, + mapBackgroundReady: Boolean( + this.mapBackground?.active && this.mapBackground.texture.key === battleScenario.mapTextureKey + ), commandMenuVisible: this.commandMenuObjects.length > 0, mapMenuVisible: this.mapMenuObjects.length > 0, saveSlotPanelVisible: this.saveSlotPanelObjects.length > 0, @@ -26830,6 +27321,31 @@ export class BattleScene extends Phaser.Scene { }, pointerFeedbackVisible: Boolean(this.pointerFeedbackMarker?.visible), pointerFeedbackTile: this.pointerFeedbackTile ? { ...this.pointerFeedbackTile } : null, + keyboardBattleCursor: this.keyboardBattleCursor ? { ...this.keyboardBattleCursor } : null, + moveIntentRiskPreview: this.lastMoveIntentRisk + ? { + tone: this.lastMoveIntentRisk.tone, + before: { ...this.lastMoveIntentRisk.before }, + after: { ...this.lastMoveIntentRisk.after }, + immediateDelta: this.lastMoveIntentRisk.immediateDelta, + expectedDamageDelta: this.lastMoveIntentRisk.expectedDamageDelta, + teamExpectedDamageDelta: this.lastMoveIntentRisk.teamExpectedDamageDelta, + retargetedToCount: this.lastMoveIntentRisk.retargetedToCount, + retargetedAwayCount: this.lastMoveIntentRisk.retargetedAwayCount, + neutralizedImmediateCount: this.lastMoveIntentRisk.neutralizedImmediateCount + } + : null, + combatExchangePreview: this.lastCombatExchangePreview + ? { + attackerId: this.lastCombatExchangePreview.attack.attacker.id, + defenderId: this.lastCombatExchangePreview.attack.defender.id, + attackDamage: this.lastCombatExchangePreview.attack.damage, + attackHitRate: this.lastCombatExchangePreview.attack.hitRate, + counterDamage: this.lastCombatExchangePreview.counter?.damage ?? 0, + counterHitRate: this.lastCombatExchangePreview.counter?.hitRate ?? 0, + projection: { ...this.lastCombatExchangePreview.projection } + } + : null, bondChainReadyPreview: this.bondChainReadyDebugState(), bondChainJudgement: this.bondChainJudgementDebugState(), markerCount: this.markers.length, @@ -26852,7 +27368,13 @@ export class BattleScene extends Phaser.Scene { ? { unitId: this.pendingMove.unit.id, from: { x: this.pendingMove.fromX, y: this.pendingMove.fromY }, - to: { x: this.pendingMove.toX, y: this.pendingMove.toY } + to: { x: this.pendingMove.toX, y: this.pendingMove.toY }, + intentRisk: { + tone: this.pendingMove.intentRisk.tone, + immediateDelta: this.pendingMove.intentRisk.immediateDelta, + expectedDamageDelta: this.pendingMove.intentRisk.expectedDamageDelta, + teamExpectedDamageDelta: this.pendingMove.intentRisk.teamExpectedDamageDelta + } } : null, actedUnitIds: Array.from(this.actedUnitIds), @@ -26953,6 +27475,33 @@ export class BattleScene extends Phaser.Scene { return battleUnits.find((unit) => unit.id === unitId); } + debugBattleUnits() { + return this.debugToolsEnabled() ? battleUnits : undefined; + } + + debugBattleSimulationState() { + if (!this.debugToolsEnabled()) { + return undefined; + } + + return { + battleOutcome: this.battleOutcome ?? null, + turnNumber: this.turnNumber, + actedUnitIds: Array.from(this.actedUnitIds), + objectives: this.objectiveStates(this.battleOutcome).map((objective) => ({ ...objective })), + battleLog: [...this.battleLog], + units: battleUnits.map((unit) => ({ + id: unit.id, + name: unit.name, + faction: unit.faction, + hp: unit.hp, + maxHp: unit.maxHp, + x: unit.x, + y: unit.y + })) + }; + } + private debugFirstPursuitRoleEffects() { return { active: this.isFirstPursuitRoleEffectBattle(), diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index ce0af4e..a73e520 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -175,7 +175,9 @@ import { ensureCampaignRosterUnits, getCampaignState, getFirstBattleReport, + getPendingCampaignVictoryRewardBattleIds, getPendingCampaignVictoryRewardReport, + getPendingCampaignVictoryRewardCategories, markCampaignStep, listCampaignSaveSlots, campaignSaveSlotCount, @@ -195,8 +197,12 @@ import { type CampaignSortiePerformanceSnapshot, type CampaignSortiePresetId, type CampaignSortieRecommendationSnapshot, + type CampaignVictoryRewardCategoryId, + type CampaignVictoryRewardReport, type FirstBattleReport } from '../state/campaignState'; +import { clearCampaignBattleSavesForSlot } from '../state/battleSaveKeys'; +import { releaseTextureSource } from '../render/loaderLifecycle'; import { palette } from '../ui/palette'; import { startLazyScene } from './lazyScenes'; @@ -255,7 +261,7 @@ type CampTabButtonView = { hovered: boolean; }; -type VictoryRewardCardId = 'gold' | 'equipment' | 'supplies' | 'reputation' | 'recruits' | 'unlocks'; +type VictoryRewardCardId = CampaignVictoryRewardCategoryId; type VictoryRewardActionId = 'equipment' | 'supplies' | 'sortie' | 'close'; @@ -11276,6 +11282,7 @@ export class CampScene extends Phaser.Scene { private campSkinTransitionRevision = 0; private campSkinTransitionCompletedRevision = 0; private campSkinTransitionActive = false; + private ownedCampTextureKeys = new Set(); private campSkinTransitionLast?: { kind: 'backdrop-fade'; durationMs: number; @@ -11310,7 +11317,7 @@ export class CampScene extends Phaser.Scene { private saveSlotObjects: Phaser.GameObjects.GameObject[] = []; private saveSlotConfirmObjects: Phaser.GameObjects.GameObject[] = []; private pendingSaveSlot?: number; - private pendingVictoryRewardReport?: FirstBattleReport; + private pendingVictoryRewardReport?: CampaignVictoryRewardReport; private victoryRewardObjects: Phaser.GameObjects.GameObject[] = []; private victoryRewardCards: VictoryRewardCardView[] = []; private victoryRewardActions: VictoryRewardActionView[] = []; @@ -11382,6 +11389,7 @@ export class CampScene extends Phaser.Scene { } init(data?: CampSceneData) { + this.ownedCampTextureKeys.clear(); this.openSortiePrepOnCreate = Boolean(data?.openSortiePrep); this.openSortieImprovementOnCreate = Boolean(data?.openSortieImprovement); this.retrySortieBattleId = data?.retryBattleId; @@ -11389,6 +11397,7 @@ export class CampScene extends Phaser.Scene { } create() { + this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.releaseOwnedCampTextures()); this.contentObjects = []; this.campRosterPage = 0; this.campRosterLayout = undefined; @@ -11667,15 +11676,27 @@ export class CampScene extends Phaser.Scene { }); if (missingCampBackdrop) { + this.ownedCampTextureKeys.add(selection.skin.textureKey); this.load.image(selection.skin.textureKey, selection.skin.assetUrl); } if (missingFirstSortieArtwork) { + this.ownedCampTextureKeys.add('story-first-sortie'); this.load.image('story-first-sortie', storyFirstSortieUrl); } - missingPortraits.forEach(({ textureKey, url }) => this.load.image(textureKey, url)); + missingPortraits.forEach(({ textureKey, url }) => { + this.ownedCampTextureKeys.add(textureKey); + this.load.image(textureKey, url); + }); this.load.start(); } + private releaseOwnedCampTextures() { + this.ownedCampTextureKeys.forEach((textureKey) => { + releaseTextureSource(this, textureKey); + }); + this.ownedCampTextureKeys.clear(); + } + private createFallbackReport(): FirstBattleReport { const allies = firstBattleUnits.filter((unit) => unit.faction === 'ally'); return { @@ -12944,6 +12965,7 @@ export class CampScene extends Phaser.Scene { this.sortiePresetUndoState = undefined; this.closeSortiePresetBrowserState(); this.hideCampSaveSlotPanel(); + clearCampaignBattleSavesForSlot(slot); this.campaign = saveCampaignState(getCampaignState(), slot); if (returnToSortiePrep) { this.showSortiePrep(); @@ -12992,15 +13014,28 @@ export class CampScene extends Phaser.Scene { } private showVictoryRewardAcknowledgement() { - const report = this.pendingVictoryRewardReport ?? getPendingCampaignVictoryRewardReport(); + let report = this.pendingVictoryRewardReport ?? getPendingCampaignVictoryRewardReport(); if (!report || report.outcome !== 'victory' || this.sortieObjects.length > 0) { return; } - this.pendingVictoryRewardReport = report; this.hideVictoryRewardAcknowledgement(); + getPendingCampaignVictoryRewardBattleIds().forEach((battleId) => { + const passiveCategories = getPendingCampaignVictoryRewardCategories(battleId) + .filter((category) => category === 'gold' || category === 'reputation'); + if (passiveCategories.length > 0) { + this.campaign = acknowledgeCampaignVictoryReward(battleId, passiveCategories); + } + }); + report = getPendingCampaignVictoryRewardReport(); + if (!report) { + this.pendingVictoryRewardReport = undefined; + return; + } + this.pendingVictoryRewardReport = report; const rewards = report.campaignRewards; const presentation = campaignVictoryRewardPresentation(report); + const pendingCategorySet = new Set(getPendingCampaignVictoryRewardCategories(report.battleId)); const categorizedRewardItems = new Set([ ...(rewards?.supplies ?? []), ...(rewards?.equipment ?? []), @@ -13097,7 +13132,7 @@ export class CampScene extends Phaser.Scene { icon: equipmentItem ? { kind: 'item', itemId: equipmentItem.id } : { kind: 'battle-ui', iconKey: 'sword' }, actionId: 'equipment', actionHint: '눌러서 장비 비교', - isNew: Boolean(equipmentReward) + isNew: Boolean(equipmentReward && pendingCategorySet.has('equipment')) }, { id: 'supplies', @@ -13106,7 +13141,8 @@ export class CampScene extends Phaser.Scene { accent: palette.green, icon: { kind: 'battle-ui', iconKey: supply?.usableId ?? 'bean' }, actionId: 'supplies', - actionHint: '눌러서 보급 확인' + actionHint: '눌러서 보급 확인', + isNew: pendingCategorySet.has('supplies') }, { id: 'reputation', @@ -13124,7 +13160,7 @@ export class CampScene extends Phaser.Scene { icon: recruit ? { kind: 'portrait', unitId: recruit.unitId } : { kind: 'battle-ui', iconKey: 'leadership' }, actionId: 'sortie', actionHint: '눌러서 편성에 배치', - isNew: Boolean(recruit) + isNew: Boolean(recruit && pendingCategorySet.has('recruits')) }, { id: 'unlocks', @@ -13134,7 +13170,7 @@ export class CampScene extends Phaser.Scene { icon: { kind: 'battle-ui', iconKey: 'terrain' }, actionId: 'sortie', actionHint: '눌러서 다음 전장 준비', - isNew: Boolean(rewards?.unlocks.length) + isNew: Boolean(rewards?.unlocks.length && pendingCategorySet.has('unlocks')) } ]; const cardWidth = 252; @@ -13200,7 +13236,7 @@ export class CampScene extends Phaser.Scene { background.setStrokeStyle(2, definition.accent, 0.78); iconPlate.setStrokeStyle(1, definition.accent, 0.72); }); - background.on('pointerdown', () => this.completeVictoryRewardAcknowledgement(definition.actionId!)); + background.on('pointerdown', () => this.completeVictoryRewardAcknowledgement(definition.actionId!, [definition.id])); } this.victoryRewardCards.push({ id: definition.id, @@ -13260,7 +13296,10 @@ export class CampScene extends Phaser.Scene { label.setDepth(depth + 3); label.setInteractive({ useHandCursor: true }); - const run = () => this.completeVictoryRewardAcknowledgement(definition.id); + const run = () => this.completeVictoryRewardAcknowledgement( + definition.id, + this.victoryRewardCategoriesForAction(definition.id) + ); [button, label].forEach((target) => { target.on('pointerover', () => button.setFillStyle(hoverFill, 0.99)); target.on('pointerout', () => button.setFillStyle(restingFill, 0.99)); @@ -13270,7 +13309,10 @@ export class CampScene extends Phaser.Scene { }); } - private completeVictoryRewardAcknowledgement(action: VictoryRewardActionId) { + private completeVictoryRewardAcknowledgement( + action: VictoryRewardActionId, + categories = this.victoryRewardCategoriesForAction(action) + ) { const report = this.pendingVictoryRewardReport; const rewardedRecruitId = report?.campaignRewards?.recruits[0]?.unitId; if (action === 'sortie' && rewardedRecruitId && this.sortieAllies().some((unit) => unit.id === rewardedRecruitId)) { @@ -13279,12 +13321,7 @@ export class CampScene extends Phaser.Scene { if (action === 'equipment' && report) { this.focusVictoryRewardEquipment(report); } - if (report) { - this.campaign = acknowledgeCampaignVictoryReward(report.battleId); - if (this.campaign.acknowledgedVictoryRewardBattleIds.includes(report.battleId)) { - this.pendingVictoryRewardReport = undefined; - } - } + this.acknowledgePendingVictoryRewardCategories(categories); soundDirector.playSelect(); this.hideVictoryRewardAcknowledgement(); this.updateTabButtons(); @@ -13304,6 +13341,33 @@ export class CampScene extends Phaser.Scene { } } + private victoryRewardCategoriesForAction(action: VictoryRewardActionId): CampaignVictoryRewardCategoryId[] { + if (action === 'equipment') { + return ['equipment']; + } + if (action === 'supplies') { + return ['supplies']; + } + if (action === 'sortie') { + return ['recruits', 'unlocks']; + } + return []; + } + + private acknowledgePendingVictoryRewardCategories(categories: readonly CampaignVictoryRewardCategoryId[]) { + if (categories.length === 0) { + return; + } + getPendingCampaignVictoryRewardBattleIds().forEach((battleId) => { + const relevantCategories = getPendingCampaignVictoryRewardCategories(battleId) + .filter((category) => categories.includes(category)); + if (relevantCategories.length > 0) { + this.campaign = acknowledgeCampaignVictoryReward(battleId, relevantCategories); + } + }); + this.pendingVictoryRewardReport = getPendingCampaignVictoryRewardReport(); + } + private showRequestedSortiePrep() { if (this.openSortieImprovementOnCreate) { this.prepareGuidedSortieImprovement(); @@ -13332,7 +13396,7 @@ export class CampScene extends Phaser.Scene { return campSupplyByLabel.get(this.rewardInventoryLabel(label)); } - private focusVictoryRewardEquipment(report: FirstBattleReport) { + private focusVictoryRewardEquipment(report: CampaignVictoryRewardReport) { const rewardItemIds = new Set( (report.campaignRewards?.equipment ?? []) .map((label) => this.itemForRewardLabel(label)?.id) @@ -13540,6 +13604,7 @@ export class CampScene extends Phaser.Scene { private updateTabButtons() { const accentColor = this.campSkinSelection?.skin.accentColor ?? palette.gold; + const pendingCategories = new Set(getPendingCampaignVictoryRewardCategories()); this.tabButtons.forEach(({ tab, bg, text, indicator, newBadge, hovered }) => { const active = this.activeTab === tab; bg.setFillStyle(active ? 0x2b4052 : hovered ? 0x243747 : 0x182431, active || hovered ? 0.98 : 0.94); @@ -13549,7 +13614,8 @@ export class CampScene extends Phaser.Scene { indicator.setVisible(active || hovered); indicator.setAlpha(active ? 1 : 0.66); newBadge?.setVisible(Boolean( - this.pendingVictoryRewardReport && (tab === 'supplies' || tab === 'equipment') + (tab === 'supplies' && pendingCategories.has('supplies')) || + (tab === 'equipment' && pendingCategories.has('equipment')) )); }); } @@ -13586,6 +13652,11 @@ export class CampScene extends Phaser.Scene { this.hideReportFormationHistory(); this.hideCampSaveSlotPanel(); this.clearContent(); + if (this.activeTab === 'equipment') { + this.acknowledgePendingVictoryRewardCategories(['equipment']); + } else if (this.activeTab === 'supplies') { + this.acknowledgePendingVictoryRewardCategories(['supplies']); + } this.visitedTabs.add(this.activeTab); this.updateTabButtons(); this.renderUnitColumn(); @@ -13621,6 +13692,7 @@ export class CampScene extends Phaser.Scene { private showSortiePrep() { const wasVisible = this.sortieObjects.length > 0; + this.acknowledgePendingVictoryRewardCategories(['recruits', 'unlocks']); this.hideReportFormationReview(); this.hideReportFormationHistory(); this.hideCampSaveSlotPanel(); diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index fa136e4..33592f4 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -30,6 +30,7 @@ import { } from '../data/storyCutscenes'; import { prologuePages, type PortraitKey, type StoryPage } from '../data/scenario'; import { campaignVictoryRewardPresentation, getFirstBattleReport } from '../state/campaignState'; +import { releaseTextureSource } from '../render/loaderLifecycle'; import { palette } from '../ui/palette'; import { ensureLazyScene, startGameScene } from './lazyScenes'; @@ -156,6 +157,7 @@ export class StoryScene extends Phaser.Scene { private activeStoryLoadErrorHandler?: (file: Phaser.Loader.File) => void; private storyAssetLoadGeneration = 0; private storyAssetFailures = new Set(); + private ownedStoryTextureKeys = new Set(); private rewardDisplay: StoryRewardDisplayModel = this.emptyRewardDisplay(); constructor() { @@ -174,6 +176,7 @@ export class StoryScene extends Phaser.Scene { this.activeStoryPageAssetRequest = undefined; this.activeStoryLoadErrorHandler = undefined; this.storyAssetFailures.clear(); + this.ownedStoryTextureKeys.clear(); this.storyAssetLoadGeneration += 1; this.rewardDisplay = this.emptyRewardDisplay(); } @@ -261,6 +264,7 @@ export class StoryScene extends Phaser.Scene { this.activeStoryLoadErrorHandler = undefined; } this.pageLoadingText = undefined; + this.releaseOwnedStoryTextures(); }); this.warmFirstBattleSceneModule(); @@ -414,10 +418,20 @@ export class StoryScene extends Phaser.Scene { } loadUnitBases(); }); - imageLoads.forEach(({ key, url }) => this.load.image(key, url)); + imageLoads.forEach(({ key, url }) => { + this.ownedStoryTextureKeys.add(key); + this.load.image(key, url); + }); this.load.start(); } + private releaseOwnedStoryTextures() { + this.ownedStoryTextureKeys.forEach((textureKey) => { + releaseTextureSource(this, textureKey); + }); + this.ownedStoryTextureKeys.clear(); + } + private storyPageAssetPlan(pageIndex: number): StoryPageAssetPlan { const page = this.pages[pageIndex]; if (!page) { diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index 6dbce67..1e2a9a3 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -7,6 +7,7 @@ import { saveCombatPresentationMode } from '../settings/combatPresentation'; import { battleIdForCampaignStep, isCampCampaignStep } from '../state/campaignRouting'; +import { readCampaignBattleResume, type CampaignBattleResume } from '../state/battleSaveStorage'; import { hasCampaignSave, listCampaignSaveSlots, @@ -283,7 +284,15 @@ export class TitleScene extends Phaser.Scene { badge.add([background, title, detail, meta]); } - private campaignSaveDetail(campaign: ReturnType, isEndingComplete: boolean) { + private campaignSaveDetail( + campaign: ReturnType, + isEndingComplete: boolean, + battleResume = readCampaignBattleResume(campaign) + ) { + if (battleResume) { + const progress = summarizeCampaignProgress(campaign); + return this.compactSaveDetail(`전투 이어하기 · ${progress.title}`); + } if (isEndingComplete) { return this.compactSaveDetail(`${Object.keys(campaign.battleHistory).length}전 완료 · ${campaign.roster.length}명 합류`); } @@ -296,10 +305,21 @@ export class TitleScene extends Phaser.Scene { return label.length > 19 ? `${label.slice(0, 18)}...` : label; } - private campaignSaveMeta(campaign: ReturnType) { + private campaignSaveMeta( + campaign: ReturnType, + battleResume = readCampaignBattleResume(campaign) + ) { + if (battleResume) { + return this.campaignBattleResumeMeta(battleResume); + } return `슬롯 ${campaign.activeSaveSlot} · ${this.formatSaveUpdatedAt(campaign.updatedAt)}`; } + private campaignBattleResumeMeta(resume: CampaignBattleResume) { + const faction = resume.activeFaction === 'ally' ? '아군 차례' : '적군 차례'; + return `슬롯 ${resume.slot} · ${resume.turnNumber}턴 · ${faction}`; + } + private formatSaveUpdatedAt(updatedAt: string) { const date = new Date(updatedAt); if (Number.isNaN(date.getTime())) { @@ -528,6 +548,7 @@ export class TitleScene extends Phaser.Scene { const campaign = enabled ? loadCampaignState(slot.slot) : undefined; const isEndingComplete = campaign?.step === 'ending-complete'; + const battleResume = campaign ? readCampaignBattleResume(campaign, slot.slot) : undefined; const title = this.add.text(-ui(122), -ui(12), `슬롯 ${slot.slot}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: uiPx(16), @@ -538,7 +559,7 @@ export class TitleScene extends Phaser.Scene { }); title.setOrigin(0, 0.5); - const detail = this.add.text(-ui(42), -ui(12), campaign ? this.campaignSaveDetail(campaign, isEndingComplete) : '저장 없음', { + const detail = this.add.text(-ui(42), -ui(12), campaign ? this.campaignSaveDetail(campaign, isEndingComplete, battleResume) : '저장 없음', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: uiPx(13), color: enabled ? '#d8b15f' : '#747c86', @@ -547,7 +568,7 @@ export class TitleScene extends Phaser.Scene { }); detail.setOrigin(0, 0.5); - const meta = this.add.text(-ui(42), ui(12), campaign ? this.campaignSaveMeta(campaign) : '빈 슬롯', { + const meta = this.add.text(-ui(42), ui(12), campaign ? this.campaignSaveMeta(campaign, battleResume) : '빈 슬롯', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: uiPx(11), color: enabled ? '#9fb0bf' : '#626b75', @@ -759,6 +780,14 @@ export class TitleScene extends Phaser.Scene { private async continueGameAsync(slot?: number) { const campaign = loadCampaignState(slot); + const battleResume = readCampaignBattleResume(campaign, slot ?? campaign.activeSaveSlot); + if (battleResume) { + await this.navigateTo('BattleScene', { + battleId: battleResume.battleId, + resumeBattleSaveSlot: battleResume.slot + }); + return; + } if (campaign.step === 'ending-complete') { await this.navigateTo('EndingScene'); return; diff --git a/src/game/state/battleSaveKeys.ts b/src/game/state/battleSaveKeys.ts new file mode 100644 index 0000000..6c960e0 --- /dev/null +++ b/src/game/state/battleSaveKeys.ts @@ -0,0 +1,108 @@ +import { battleScenarios } from '../data/battles'; + +export type BattleSaveKeyStorageLike = Pick; + +export const legacyFirstBattleSaveStorageKey = 'heros-web:first-battle-state'; +export const defaultBattleSaveSlotCount = 3; + +export function battleSaveStorageBaseKey(battleId: string) { + return `heros-web:battle:${battleId}`; +} + +export function battleSaveStorageSlotKey( + battleId: string, + slot: number, + slotCount = defaultBattleSaveSlotCount +) { + return `${battleSaveStorageBaseKey(battleId)}:slot-${normalizeBattleSaveKeySlot(slot, slotCount)}`; +} + +export function campaignBattleResumeStorageKeys( + battleId: string, + slot: number, + slotCount = defaultBattleSaveSlotCount +) { + const normalizedSlot = normalizeBattleSaveKeySlot(slot, slotCount); + const keys = [battleSaveStorageSlotKey(battleId, normalizedSlot, slotCount)]; + if (normalizedSlot === 1) { + keys.push(battleSaveStorageBaseKey(battleId)); + if (battleId === 'first-battle-zhuo-commandery') { + keys.push(legacyFirstBattleSaveStorageKey); + } + } + return [...new Set(keys)]; +} + +export function allCampaignBattleSaveStorageKeys(slotCount = defaultBattleSaveSlotCount) { + return [ + ...Object.keys(battleScenarios).flatMap((battleId) => [ + battleSaveStorageBaseKey(battleId), + ...Array.from({ length: slotCount }, (_, index) => battleSaveStorageSlotKey(battleId, index + 1, slotCount)) + ]), + legacyFirstBattleSaveStorageKey + ]; +} + +export function clearBattleSaveStorageForBattle( + battleId: string, + slot: number, + storage = browserStorage(), + slotCount = defaultBattleSaveSlotCount +) { + if (!storage) { + return 0; + } + return removeStorageKeys(storage, campaignBattleResumeStorageKeys(battleId, slot, slotCount)); +} + +export function clearAllCampaignBattleSaves( + storage = browserStorage(), + slotCount = defaultBattleSaveSlotCount +) { + if (!storage) { + return 0; + } + return removeStorageKeys(storage, allCampaignBattleSaveStorageKeys(slotCount)); +} + +export function clearCampaignBattleSavesForSlot( + slot: number, + storage = browserStorage(), + slotCount = defaultBattleSaveSlotCount +) { + if (!storage) { + return 0; + } + const keys = Object.keys(battleScenarios).flatMap((battleId) => + campaignBattleResumeStorageKeys(battleId, slot, slotCount) + ); + return removeStorageKeys(storage, [...new Set(keys)]); +} + +function removeStorageKeys(storage: BattleSaveKeyStorageLike, keys: readonly string[]) { + return keys.reduce((removedCount, key) => { + try { + if (storage.getItem(key) === null) { + return removedCount; + } + storage.removeItem(key); + return removedCount + 1; + } catch { + return removedCount; + } + }, 0); +} + +function normalizeBattleSaveKeySlot(slot: number, slotCount: number) { + const safeSlotCount = Number.isFinite(slotCount) ? Math.max(1, Math.floor(slotCount)) : defaultBattleSaveSlotCount; + const safeSlot = Number.isFinite(slot) ? Math.floor(slot) : 1; + return Math.min(Math.max(safeSlot, 1), safeSlotCount); +} + +function browserStorage() { + try { + return typeof window === 'undefined' ? undefined : window.localStorage; + } catch { + return undefined; + } +} diff --git a/src/game/state/battleSaveState.ts b/src/game/state/battleSaveState.ts index e4e347f..c0980ea 100644 --- a/src/game/state/battleSaveState.ts +++ b/src/game/state/battleSaveState.ts @@ -23,6 +23,7 @@ export type BattleSaveBondState = BattleBond & { }; export type SavedBattleUnitState = Pick & { + stats?: UnitData['stats']; equipment: UnitData['equipment']; direction?: UnitDirection; }; @@ -112,7 +113,7 @@ export type BattleSaveState = { triggeredBattleEvents?: string[]; }; -type BattleSaveValidationOptions = { +export type BattleSaveValidationOptions = { expectedBattleId?: string; allowTacticalCommand?: boolean; allowLegacyTacticalCommand?: boolean; @@ -144,6 +145,7 @@ const maxBattleUnitStatValue = 999999; const maxBattleUnitHp = 9999; const maxBattleUnitAttack = 9999; const maxBattleUnitMove = 99; +const maxBattleUnitAttribute = 999; const maxBattleBondBattleExp = 999999; const maxStatusKindsPerUnit = 2; const tacticalCommandCounterplayThreshold = 3; @@ -547,6 +549,7 @@ function isSavedBattleUnitState(value: unknown, options: BattleSaveValidationOpt Number(value.hp) > Number(value.maxHp) || !isIntegerInRange(value.attack, 0, maxBattleUnitAttack) || !isIntegerInRange(value.move, 0, maxBattleUnitMove) || + !isOptionalUnitAttributes(value.stats) || !Number.isInteger(value.x) || !Number.isInteger(value.y) ) { @@ -564,6 +567,20 @@ function isSavedBattleUnitState(value: unknown, options: BattleSaveValidationOpt return value.direction === undefined || unitDirections.has(value.direction as UnitDirection); } +function isOptionalUnitAttributes(value: unknown): value is UnitData['stats'] | undefined { + if (value === undefined) { + return true; + } + if (!isRecord(value)) { + return false; + } + const keys: Array = ['might', 'intelligence', 'leadership', 'agility', 'luck']; + return ( + Object.keys(value).length === keys.length && + keys.every((key) => isIntegerInRange(value[key], 0, maxBattleUnitAttribute)) + ); +} + function isEquipmentSet(value: unknown, options: BattleSaveValidationOptions): value is UnitData['equipment'] { if (!isRecord(value) || Object.keys(value).length !== equipmentSlots.length) { return false; diff --git a/src/game/state/battleSaveStorage.ts b/src/game/state/battleSaveStorage.ts new file mode 100644 index 0000000..aa92a72 --- /dev/null +++ b/src/game/state/battleSaveStorage.ts @@ -0,0 +1,159 @@ +import type { BattleScenarioId } from '../data/battles'; +import { + parseBattleSaveState, + normalizeBattleSaveSlot, + type BattleSaveFaction, + type BattleSaveState, + type BattleSaveValidationOptions +} from './battleSaveState'; +import { + battleSaveStorageBaseKey, + battleSaveStorageSlotKey, + campaignBattleResumeStorageKeys, + clearAllCampaignBattleSaves, + clearCampaignBattleSavesForSlot, + clearBattleSaveStorageForBattle, + legacyFirstBattleSaveStorageKey, + type BattleSaveKeyStorageLike +} from './battleSaveKeys'; +import { battleIdForCampaignStep } from './campaignRouting'; +import type { CampaignStep } from './campaignState'; + +export type BattleSaveStorageLike = BattleSaveKeyStorageLike; + +export { + battleSaveStorageBaseKey, + battleSaveStorageSlotKey, + campaignBattleResumeStorageKeys, + clearAllCampaignBattleSaves, + clearCampaignBattleSavesForSlot, + clearBattleSaveStorageForBattle, + legacyFirstBattleSaveStorageKey +}; + +export type CampaignBattleResumeSource = { + step: CampaignStep; + activeSaveSlot: number; +}; + +export type CampaignBattleResume = { + slot: number; + battleId: BattleScenarioId; + campaignStep: CampaignStep; + storageKey: string; + savedAt: string; + turnNumber: number; + activeFaction: BattleSaveFaction; +}; + +export type BattleSaveStorageCandidate = { + storageKey: string; + state: BattleSaveState; +}; + +export function readBattleSaveStorageCandidate( + battleId: BattleScenarioId, + campaignStep: CampaignStep | undefined, + slot: number, + storage = browserStorage(), + slotCount = 3, + validationOptions: BattleSaveValidationOptions = {} +): BattleSaveStorageCandidate | undefined { + if (!storage) { + return undefined; + } + const normalizedSlot = normalizeBattleSaveSlot(slot, slotCount); + for (const storageKey of campaignBattleResumeStorageKeys(battleId, normalizedSlot, slotCount)) { + const raw = safeGetItem(storage, storageKey); + if (raw === null) { + continue; + } + const state = parseBattleSaveState(raw, { + ...validationOptions, + expectedBattleId: battleId + }); + if (state && (state.campaignStep === undefined || state.campaignStep === campaignStep)) { + return { storageKey, state }; + } + safeRemoveItem(storage, storageKey); + } + return undefined; +} + +export function readCampaignBattleResume( + campaign: CampaignBattleResumeSource, + slot = campaign.activeSaveSlot, + storage = browserStorage(), + slotCount = 3 +): CampaignBattleResume | undefined { + const battleId = battleIdForCampaignStep(campaign.step); + if (!battleId || !storage) { + return undefined; + } + + const normalizedSlot = normalizeBattleSaveSlot(slot, slotCount); + const candidate = readBattleSaveStorageCandidate( + battleId, + campaign.step, + normalizedSlot, + storage, + slotCount, + { allowTacticalCommand: true, allowLegacyTacticalCommand: true } + ); + if (candidate) { + return { + slot: normalizedSlot, + battleId, + campaignStep: campaign.step, + storageKey: candidate.storageKey, + savedAt: candidate.state.savedAt, + turnNumber: candidate.state.turnNumber, + activeFaction: candidate.state.activeFaction + }; + } + return undefined; +} + +export function clearCampaignBattleResume( + campaign: CampaignBattleResumeSource, + slot = campaign.activeSaveSlot, + storage = browserStorage(), + slotCount = 3 +) { + const battleId = battleIdForCampaignStep(campaign.step); + if (!battleId || !storage) { + return false; + } + let removed = false; + campaignBattleResumeStorageKeys(battleId, slot, slotCount).forEach((storageKey) => { + if (safeGetItem(storage, storageKey) !== null) { + removed = safeRemoveItem(storage, storageKey) || removed; + } + }); + return removed; +} + +function browserStorage() { + try { + return typeof window === 'undefined' ? undefined : window.localStorage; + } catch { + return undefined; + } +} + +function safeGetItem(storage: BattleSaveStorageLike, key: string) { + try { + return storage.getItem(key); + } catch { + return null; + } +} + +function safeRemoveItem(storage: BattleSaveStorageLike, key: string) { + try { + storage.removeItem(key); + return true; + } catch { + return false; + } +} diff --git a/src/game/state/campaignRouting.ts b/src/game/state/campaignRouting.ts index 036247f..ce1ec55 100644 --- a/src/game/state/campaignRouting.ts +++ b/src/game/state/campaignRouting.ts @@ -74,6 +74,12 @@ export function battleIdForCampaignStep(step: CampaignStep) { return battleIdByCampaignStep[step]; } +export function campaignBattleRouteEntries() { + return Object.entries(battleIdByCampaignStep) + .filter((entry): entry is [CampaignStep, BattleScenarioId] => Boolean(entry[1])) + .map(([step, battleId]) => ({ step, battleId })); +} + export function isCampCampaignStep(step: CampaignStep) { return step.endsWith('-camp'); } diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 82d8874..bd38e49 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -25,6 +25,7 @@ import { type SortieOrderResultSnapshot } from '../data/sortieOrders'; import { battleIdForCampaignStep, isCampCampaignStep } from './campaignRouting'; +import { clearAllCampaignBattleSaves, clearCampaignBattleSavesForSlot } from './battleSaveKeys'; export type BattleObjectiveSnapshot = { id: string; @@ -65,6 +66,19 @@ export type CampaignRewardSnapshot = { note?: string; }; +export const campaignVictoryRewardCategoryIds = [ + 'gold', + 'equipment', + 'supplies', + 'reputation', + 'recruits', + 'unlocks' +] as const; +export type CampaignVictoryRewardCategoryId = (typeof campaignVictoryRewardCategoryIds)[number]; +export type CampaignVictoryRewardAcknowledgements = Partial>; + +const campaignVictoryRewardCategoryIdSet = new Set(campaignVictoryRewardCategoryIds); + export type CampaignSortieUnitPerformanceSnapshot = { unitId: string; hpBefore: number; @@ -472,6 +486,7 @@ export type CampaignState = { completedCampDialogues: string[]; completedCampVisits: string[]; acknowledgedVictoryRewardBattleIds: string[]; + acknowledgedVictoryRewardCategories: CampaignVictoryRewardAcknowledgements; battleHistory: Record; latestBattleId?: string; firstBattleReport?: FirstBattleReport; @@ -678,12 +693,14 @@ export function createInitialCampaignState(): CampaignState { completedCampDialogues: [], completedCampVisits: [], acknowledgedVictoryRewardBattleIds: [], + acknowledgedVictoryRewardCategories: {}, battleHistory: {} }; } export function startNewCampaign() { const state = createInitialCampaignState(); + clearCampaignBattleSavesForSlot(state.activeSaveSlot); state.step = 'prologue'; return setCampaignState(state); } @@ -718,6 +735,7 @@ export function saveCampaignState(state = ensureCampaignState(), slot = state.ac } export function resetCampaignState() { + clearAllCampaignBattleSaves(); campaignState = createInitialCampaignState(); tryStorage()?.removeItem(campaignStorageKey); for (let slot = 1; slot <= campaignSaveSlotCount; slot += 1) { @@ -1027,7 +1045,12 @@ export function getFirstBattleReport() { return report ? cloneReport(report) : undefined; } -export function campaignVictoryRewardPresentation(report: FirstBattleReport): CampaignVictoryRewardPresentation { +export type CampaignVictoryRewardReport = Pick< + FirstBattleReport, + 'battleId' | 'battleTitle' | 'outcome' | 'rewardGold' | 'itemRewards' | 'campaignRewards' | 'sortieOrder' +>; + +export function campaignVictoryRewardPresentation(report: CampaignVictoryRewardReport): CampaignVictoryRewardPresentation { const definition = report.outcome === 'victory' && report.sortieOrder?.rewardGranted ? sortieOrderDefinition(report.sortieOrder.orderId) : undefined; @@ -1047,46 +1070,182 @@ export function campaignVictoryRewardPresentation(report: FirstBattleReport): Ca }; } -export function getPendingCampaignVictoryRewardReport() { - const state = ensureCampaignState(); - const report = state.firstBattleReport; - if ( - !report || - report.outcome !== 'victory' || - state.acknowledgedVictoryRewardBattleIds.includes(report.battleId) - ) { - return undefined; +type CampaignVictoryRewardSource = Pick< + FirstBattleReport, + 'battleId' | 'rewardGold' | 'itemRewards' | 'campaignRewards' | 'sortieOrder' +>; + +export function campaignVictoryRewardCategoriesFor( + report: CampaignVictoryRewardSource +): CampaignVictoryRewardCategoryId[] { + const categories: CampaignVictoryRewardCategoryId[] = []; + const rewards = report.campaignRewards; + const orderDefinition = report.sortieOrder?.rewardGranted + ? sortieOrderDefinition(report.sortieOrder.orderId) + : undefined; + if (report.rewardGold + (orderDefinition?.rewardGold ?? 0) > 0) { + categories.push('gold'); } - return cloneReport(report); + const categorizedRewardItems = new Set([ + ...(rewards?.supplies ?? []), + ...(rewards?.equipment ?? []), + ...(rewards?.reputation ?? []) + ]); + const legacyExtraItems = report.itemRewards.filter((item) => !categorizedRewardItems.has(item)); + const supplyItems = rewards + ? [...rewards.supplies, ...legacyExtraItems, ...(orderDefinition?.rewardItems ?? [])] + : [...report.itemRewards, ...(orderDefinition?.rewardItems ?? [])]; + if ((rewards?.equipment.length ?? 0) > 0) { + categories.push('equipment'); + } + if (supplyItems.length > 0) { + categories.push('supplies'); + } + if ((rewards?.reputation.length ?? 0) > 0) { + categories.push('reputation'); + } + if ((rewards?.recruits.length ?? 0) > 0) { + categories.push('recruits'); + } + if ((rewards?.unlocks.length ?? 0) > 0) { + categories.push('unlocks'); + } + return categories; } -export function acknowledgeCampaignVictoryReward(battleId: string) { +export function getPendingCampaignVictoryRewardCategories(battleId?: string) { + const state = ensureCampaignState(); + if (battleId === undefined) { + const pending = new Set( + campaignVictoryRewardBattleIds(state).flatMap((candidateBattleId) => + pendingCampaignVictoryRewardCategoriesFor(state, candidateBattleId) + ) + ); + return campaignVictoryRewardCategoryIds.filter((category) => pending.has(category)); + } + return pendingCampaignVictoryRewardCategoriesFor(state, normalizeKeyString(battleId)); +} + +export function getPendingCampaignVictoryRewardBattleIds() { + const state = ensureCampaignState(); + return campaignVictoryRewardBattleIds(state).filter( + (battleId) => pendingCampaignVictoryRewardCategoriesFor(state, battleId).length > 0 + ); +} + +function pendingCampaignVictoryRewardCategoriesFor(state: CampaignState, normalizedBattleId: string) { + const report = campaignVictoryRewardSourceFor(state, normalizedBattleId); + if (!normalizedBattleId || !report || !isCampaignVictory(report)) { + return [] as CampaignVictoryRewardCategoryId[]; + } + if (state.acknowledgedVictoryRewardBattleIds.includes(normalizedBattleId)) { + return [] as CampaignVictoryRewardCategoryId[]; + } + const acknowledged = new Set(state.acknowledgedVictoryRewardCategories[normalizedBattleId] ?? []); + return campaignVictoryRewardCategoriesFor(report).filter((category) => !acknowledged.has(category)); +} + +export function getPendingCampaignVictoryRewardReport() { + const state = ensureCampaignState(); + const battleId = [...campaignVictoryRewardBattleIds(state)] + .reverse() + .find((candidateBattleId) => pendingCampaignVictoryRewardCategoriesFor(state, candidateBattleId).length > 0); + const report = battleId ? campaignVictoryRewardSourceFor(state, battleId) : undefined; + if (!report || report.outcome !== 'victory') { + return undefined; + } + return cloneCampaignVictoryRewardReport(report); +} + +export function acknowledgeCampaignVictoryReward( + battleId: string, + requestedCategories?: readonly CampaignVictoryRewardCategoryId[] +) { const state = ensureCampaignState(); const normalizedBattleId = normalizeKeyString(battleId); - const report = state.firstBattleReport; - const settlement = state.battleHistory[normalizedBattleId]; - const hasVictory = settlement?.outcome === 'victory' || ( - report?.battleId === normalizedBattleId && report.outcome === 'victory' - ); + const report = campaignVictoryRewardSourceFor(state, normalizedBattleId); if ( !normalizedBattleId || !(normalizedBattleId in battleScenarios) || - !hasVictory + !report || + !isCampaignVictory(report) ) { return cloneCampaignState(state); } - if (!state.acknowledgedVictoryRewardBattleIds.includes(normalizedBattleId)) { + const availableCategories = campaignVictoryRewardCategoriesFor(report); + const availableCategorySet = new Set(availableCategories); + const categories = requestedCategories === undefined + ? availableCategories + : uniqueVictoryRewardCategories(requestedCategories).filter((category) => availableCategorySet.has(category)); + const previousCategories = state.acknowledgedVictoryRewardCategories[normalizedBattleId] ?? []; + const acknowledgedCategories = uniqueVictoryRewardCategories([...previousCategories, ...categories]); + const fullyAcknowledged = availableCategories.length > 0 && + availableCategories.every((category) => acknowledgedCategories.includes(category)); + const categoryChanged = JSON.stringify(previousCategories) !== JSON.stringify(acknowledgedCategories); + const battleAcknowledged = state.acknowledgedVictoryRewardBattleIds.includes(normalizedBattleId); + if (categoryChanged) { + state.acknowledgedVictoryRewardCategories = { + ...state.acknowledgedVictoryRewardCategories, + [normalizedBattleId]: acknowledgedCategories + }; + } + if (fullyAcknowledged && !battleAcknowledged) { state.acknowledgedVictoryRewardBattleIds = [ ...state.acknowledgedVictoryRewardBattleIds, normalizedBattleId ]; + } + if (categoryChanged || (fullyAcknowledged && !battleAcknowledged)) { state.updatedAt = new Date().toISOString(); persistCampaignState(state); } return cloneCampaignState(state); } +function campaignVictoryRewardSourceFor(state: CampaignState, battleId: string) { + if (state.firstBattleReport?.battleId === battleId) { + return state.firstBattleReport; + } + return state.battleHistory[battleId]; +} + +function campaignVictoryRewardBattleIds(state: CampaignState) { + const battleIds = Object.keys(state.battleHistory).filter((battleId) => isCampaignVictory(state.battleHistory[battleId])); + const latestReportBattleId = state.firstBattleReport?.outcome === 'victory' + ? state.firstBattleReport.battleId + : undefined; + if (latestReportBattleId && !battleIds.includes(latestReportBattleId)) { + battleIds.push(latestReportBattleId); + } + return battleIds; +} + +function cloneCampaignVictoryRewardReport( + report: CampaignVictoryRewardReport +): CampaignVictoryRewardReport { + return { + battleId: report.battleId, + battleTitle: report.battleTitle, + outcome: report.outcome, + rewardGold: report.rewardGold, + itemRewards: normalizeRewardStrings(report.itemRewards), + campaignRewards: cloneCampaignRewardSnapshot(report.campaignRewards, report.battleId), + ...(report.sortieOrder + ? { sortieOrder: JSON.parse(JSON.stringify(report.sortieOrder)) as CampaignSortieOrderResultSnapshot } + : {}) + }; +} + +function isCampaignVictory(report: FirstBattleReport | CampaignBattleSettlement) { + return report.outcome === 'victory'; +} + +function uniqueVictoryRewardCategories(value: unknown): CampaignVictoryRewardCategoryId[] { + const categories = new Set(uniqueStrings(value).filter((category) => campaignVictoryRewardCategoryIdSet.has(category))); + return campaignVictoryRewardCategoryIds.filter((category) => categories.has(category)); +} + export function applyCampBondExp(dialogueId: string, bondId: string, amount: number) { const state = ensureCampaignState(); if (!state.firstBattleReport) { @@ -1346,6 +1505,9 @@ function normalizeCampaignState(state: Partial): CampaignState { .filter((id): id is CampaignTutorialId => campaignTutorialIdSet.has(id)); normalized.acknowledgedVictoryRewardBattleIds = uniqueStrings(normalized.acknowledgedVictoryRewardBattleIds) .filter((battleId) => battleId in battleScenarios); + normalized.acknowledgedVictoryRewardCategories = normalizeCampaignVictoryRewardAcknowledgements( + normalized.acknowledgedVictoryRewardCategories + ); normalized.inventory = normalizeInventory(normalized.inventory); normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory); normalized.firstBattleReport = normalizeFirstBattleReport(normalized.firstBattleReport); @@ -1415,6 +1577,40 @@ function normalizeCampaignState(state: Partial): CampaignState { } normalized.acknowledgedVictoryRewardBattleIds = normalized.acknowledgedVictoryRewardBattleIds .filter((battleId) => recordedVictoryBattleIds.has(battleId)); + const acknowledgedVictoryRewardCategories = Object.entries(normalized.acknowledgedVictoryRewardCategories) + .reduce((acknowledgements, [battleId, categories]) => { + if (!recordedVictoryBattleIds.has(battleId)) { + return acknowledgements; + } + const source = campaignVictoryRewardSourceFor(normalized, battleId); + if (!source || !isCampaignVictory(source)) { + return acknowledgements; + } + const availableCategories = new Set(campaignVictoryRewardCategoriesFor(source)); + const validCategories = uniqueVictoryRewardCategories(categories) + .filter((category) => availableCategories.has(category)); + if (validCategories.length > 0) { + acknowledgements[battleId] = validCategories; + } + return acknowledgements; + }, {}); + normalized.acknowledgedVictoryRewardBattleIds.forEach((battleId) => { + const source = campaignVictoryRewardSourceFor(normalized, battleId); + if (source && isCampaignVictory(source)) { + acknowledgedVictoryRewardCategories[battleId] = campaignVictoryRewardCategoriesFor(source); + } + }); + normalized.acknowledgedVictoryRewardCategories = acknowledgedVictoryRewardCategories; + normalized.acknowledgedVictoryRewardBattleIds = [...recordedVictoryBattleIds] + .filter((battleId) => { + const source = campaignVictoryRewardSourceFor(normalized, battleId); + if (!source || !isCampaignVictory(source)) { + return false; + } + const acknowledged = new Set(normalized.acknowledgedVictoryRewardCategories[battleId] ?? []); + const available = campaignVictoryRewardCategoriesFor(source); + return available.length > 0 && available.every((category) => acknowledged.has(category)); + }); normalized.sortieOrderHistory = normalizeCampaignSortieOrderHistory( normalized.sortieOrderHistory, normalized.battleHistory, @@ -1435,6 +1631,19 @@ function recordOrEmpty(value: unknown): Record { return isPlainObject(value) ? value as Record : {}; } +function normalizeCampaignVictoryRewardAcknowledgements(value: unknown): CampaignVictoryRewardAcknowledgements { + return Object.entries(recordOrEmpty(value)) + .slice(0, maxCampaignStringListEntries) + .reduce((acknowledgements, [battleId, categories]) => { + const normalizedBattleId = normalizeKeyString(battleId); + const normalizedCategories = uniqueVictoryRewardCategories(categories); + if (normalizedBattleId && normalizedBattleId in battleScenarios && normalizedCategories.length > 0) { + acknowledgements[normalizedBattleId] = normalizedCategories; + } + return acknowledgements; + }, {}); +} + function uniqueStrings(value: unknown): string[] { return [ ...new Set( diff --git a/src/main.ts b/src/main.ts index 01a45bf..1d3e8a4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -50,6 +50,9 @@ const config: Phaser.Types.Core.GameConfig = { width: 1920, height: 1080, backgroundColor: '#10131a', + loader: { + imageLoadType: 'HTMLImageElement' + }, scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH