From f83296db7b9f8c313b2e3046ed11997353112def Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 19 Jul 2026 12:16:30 +0900 Subject: [PATCH] feat: enrich campaign reward and roster visuals --- docs/visual-asset-quality-audit.md | 296 +++++++++---------- package.json | 1 + scripts/audit-visual-asset-quality.mjs | 52 ++-- scripts/verify-release-candidate.mjs | 45 ++- scripts/verify-sortie-roster-history.mjs | 124 ++++++++ scripts/verify-static-data.mjs | 1 + scripts/verify-story-asset-data.mjs | 142 ++++++++- src/game/data/sortieRosterHistory.ts | 257 ++++++++++++++++ src/game/data/storyAssets.ts | 84 ++++++ src/game/scenes/CampScene.ts | 354 +++++++++++++++++++++-- src/game/scenes/StoryScene.ts | 13 +- 11 files changed, 1164 insertions(+), 205 deletions(-) create mode 100644 scripts/verify-sortie-roster-history.mjs create mode 100644 src/game/data/sortieRosterHistory.ts diff --git a/docs/visual-asset-quality-audit.md b/docs/visual-asset-quality-audit.md index 89f1740..482f297 100644 --- a/docs/visual-asset-quality-audit.md +++ b/docs/visual-asset-quality-audit.md @@ -11,7 +11,7 @@ Generated by `scripts/audit-visual-asset-quality.mjs`. - Battle map source pixel density range: 60.0-96.0 px/tile - Story source backgrounds: 136 - Story page background references: 466 -- Flagged story background review rows: 120 +- Flagged story background review rows: 17 ## Main Findings @@ -25,7 +25,7 @@ Generated by `scripts/audit-visual-asset-quality.mjs`. - Battle map candidate: `sixty-first-battle-hanzhong-rain-defense` / `battle-map-sixty-first` - Battle reason: watch tile pixel density -- Story background candidate: `src/assets/images/story/25-yizhou-luo-road.png` +- Story background candidate: `src/assets/images/story/38-yizhou-luo-road-supply-train.png` - Story reason: high-repeat story background - Suggested workflow: capture the top battle candidate in a 1920x1080 battle scene, inspect terrain readability under units/grid, then regenerate or repaint only that asset if the screenshot confirms the issue. @@ -45,18 +45,18 @@ Generated by `scripts/audit-visual-asset-quality.mjs`. | score | source | page refs | image | bytes/px | concerns | examples | | ---: | --- | ---: | ---: | ---: | --- | --- | -| 4 | `src/assets/images/story/25-yizhou-luo-road.png` | 49 | 1672x941 | 2.4004 | high-repeat story background | `twentySeventhBattleIntroPages/twenty-seventh-yizhou-request`
`twentySeventhBattleIntroPages/twenty-seventh-pang-tong-rumor`
`twentySeventhBattleIntroPages/twenty-seventh-gate-warning` | -| 4 | `src/assets/images/story/28-nanzhong-seven-captures.png` | 42 | 1672x941 | 2.4106 | high-repeat story background | `fortyEighthBattleIntroPages/forty-eighth-meng-huo-gathers`
`fortyEighthBattleIntroPages/forty-eighth-zhuge-amnesty`
`fortyEighthBattleIntroPages/forty-eighth-sortie` | -| 4 | `src/assets/images/story/09-wandering-road.png` | 39 | 1672x941 | 1.3390 | high-repeat story background | `tenthBattleIntroPages/tenth-zhang-fei-fault`
`tenthBattleIntroPages/tenth-lu-bu-takes-xuzhou`
`tenthBattleIntroPages/tenth-brothers-regroup` | -| 4 | `src/assets/images/story/13-northern-expedition.png` | 36 | 1672x941 | 1.5690 | high-repeat story background | `thirtyFifthBattleIntroPages/thirty-fifth-hanzhong-front`
`thirtyFifthBattleIntroPages/thirty-fifth-ma-chao-first-sortie`
`thirtyFifthBattleIntroPages/thirty-fifth-huang-quan-map` | -| 4 | `src/assets/images/story/12-yizhou-mountain-pass.png` | 29 | 1672x941 | 1.6969 | high-repeat story background | `twentyThirdBattleIntroPages/twenty-third-jingzhou-cause`
`twentyThirdBattleIntroPages/twenty-third-zhuge-plan`
`twentyThirdBattleIntroPages/twenty-third-ma-liang-rumor` | -| 4 | `src/assets/images/story/11-red-cliffs-fire.png` | 28 | 1672x941 | 1.5183 | high-repeat story background | `nineteenthBattleIntroPages/nineteenth-cao-army-south`
`nineteenthBattleIntroPages/nineteenth-zhao-yun-rescue`
`nineteenthBattleIntroPages/nineteenth-zhang-fei-bridge` | -| 4 | `src/assets/images/story/06-yellow-turban-pursuit.png` | 26 | 1672x941 | 1.5888 | high-repeat story background | `firstBattleVictoryPages/first-victory-village`
`firstBattleVictoryPages/first-victory-liu-bei`
`firstBattleVictoryPages/first-victory-guan-yu` | -| 4 | `src/assets/images/story/29-fan-castle-flood.png` | 21 | 1672x941 | 1.5085 | high-repeat story background | `thirtyNinthBattleIntroPages/thirty-ninth-fan-castle-shadow`
`thirtyNinthBattleIntroPages/thirty-ninth-guan-yu-north`
`thirtyNinthBattleIntroPages/thirty-ninth-flooded-fields` | -| 4 | `src/assets/images/story/10-wolong-cottage.png` | 21 | 1672x941 | 1.6899 | high-repeat story background | `sixteenthBattleIntroPages/sixteenth-yuan-camp-shifts`
`sixteenthBattleIntroPages/sixteenth-zhao-yun-returns`
`sixteenthBattleIntroPages/sixteenth-liu-biao-road` | -| 4 | `src/assets/images/story/27-jingzhou-crisis.png` | 21 | 1672x941 | 2.1512 | high-repeat story background | `thirtyEighthBattleIntroPages/thirty-eighth-jing-warning`
`thirtyEighthBattleIntroPages/thirty-eighth-guan-yu-front`
`thirtyEighthBattleIntroPages/thirty-eighth-two-fronts` | -| 4 | `src/assets/images/story/08-xuzhou-handover.png` | 18 | 1672x941 | 1.3700 | high-repeat story background | `seventhBattleIntroPages/seventh-xuzhou-envoy-arrives`
`seventhBattleIntroPages/seventh-liu-bei-xuzhou`
`seventhBattleIntroPages/seventh-guan-yu-field` | -| 4 | `src/assets/images/story/18-jieting-crisis.png` | 13 | 1672x941 | 1.5299 | high-repeat story background | `fiftySixthBattleVictoryPages/fifty-sixth-next-jieting`
`fiftySeventhBattleIntroPages/fifty-seventh-jieting-order`
`fiftySeventhBattleIntroPages/fifty-seventh-ma-su-insists` | +| 4 | `src/assets/images/story/38-yizhou-luo-road-supply-train.png` | 14 | 1672x941 | 1.9711 | high-repeat story background | `twentyFifthBattleIntroPages/twenty-fifth-wuling-sortie`
`twentySixthBattleVictoryPages/twenty-sixth-victory-changsha-open`
`twentySixthBattleVictoryPages/twenty-sixth-next-yi-province` | +| 4 | `src/assets/images/story/89-yizhou-luo-road-hidden-bridge.png` | 13 | 1672x941 | 1.6125 | high-repeat story background | `twentyFifthBattleIntroPages/twenty-fifth-wuling-road`
`twentyFifthBattleIntroPages/twenty-fifth-zhuge-formation`
`twentySeventhBattleIntroPages/twenty-seventh-pang-tong-rumor` | +| 2 | `src/assets/images/story/13-northern-expedition.png` | 11 | 1672x941 | 1.5690 | repeat story background | `thirtyFifthBattleIntroPages/thirty-fifth-sortie`
`thirtyFifthBattleVictoryPages/thirty-fifth-victory-pass-opened`
`thirtySixthBattleIntroPages/thirty-sixth-huang-zhong-plan` | +| 2 | `src/assets/images/story/66-yizhou-luo-road-mountain-scouts.png` | 11 | 1672x941 | 1.7281 | repeat story background | `twentyThirdBattleVictoryPages/twenty-third-ma-liang-joins`
`twentyFourthBattleIntroPages/twenty-fourth-guiyang-cause`
`twentyFifthBattleIntroPages/twenty-fifth-yi-ji-letter` | +| 2 | `src/assets/images/story/09-wandering-road.png` | 10 | 1672x941 | 1.3390 | repeat story background | `tenthBattleIntroPages/tenth-brothers-regroup`
`tenthBattleVictoryPages/tenth-cao-cao-shadow`
`eleventhBattleIntroPages/eleventh-cao-cao-test` | +| 2 | `src/assets/images/story/40-nanzhong-captures-river-ford.png` | 10 | 1672x941 | 1.8187 | repeat story background | `fortySeventhBattleIntroPages/forty-seventh-zhao-yun-vanguard`
`fortyEighthBattleIntroPages/forty-eighth-zhuge-amnesty`
`fortyNinthBattleVictoryPages/forty-ninth-victory-second-capture` | +| 2 | `src/assets/images/story/25-yizhou-luo-road.png` | 10 | 1672x941 | 2.4004 | repeat story background | `twentySeventhBattleIntroPages/twenty-seventh-yizhou-request`
`twentySeventhBattleIntroPages/twenty-seventh-gate-warning`
`twentySeventhBattleVictoryPages/twenty-seventh-victory-road-open` | +| 2 | `src/assets/images/story/37-yizhou-luo-road-ambush.png` | 9 | 1672x941 | 1.7313 | repeat story background | `twentyThirdBattleIntroPages/twenty-third-zhuge-plan`
`twentyFourthBattleIntroPages/twenty-fourth-yi-ji-rumor`
`twentySeventhBattleVictoryPages/twenty-seventh-next-fu-pass` | +| 2 | `src/assets/images/story/72-northern-mountain-war-council.png` | 8 | 1672x941 | 1.3639 | repeat story background | `thirtyFifthBattleIntroPages/thirty-fifth-hanzhong-front`
`thirtySixthBattleIntroPages/thirty-sixth-dingjun-mountain`
`thirtySixthBattleIntroPages/thirty-sixth-western-cavalry` | +| 2 | `src/assets/images/story/12-yizhou-mountain-pass.png` | 8 | 1672x941 | 1.6969 | repeat story background | `twentyThirdBattleIntroPages/twenty-third-ma-liang-rumor`
`twentyThirdBattleVictoryPages/twenty-third-victory-southern-road`
`twentyThirdBattleVictoryPages/twenty-third-next-commandery` | +| 2 | `src/assets/images/story/28-nanzhong-seven-captures.png` | 8 | 1672x941 | 2.4106 | repeat story background | `fortySeventhBattleIntroPages/forty-seventh-zhuge-policy`
`fortySeventhBattleVictoryPages/forty-seventh-next-nanman`
`fortyEighthBattleVictoryPages/forty-eighth-next-capture` | +| 2 | `src/assets/images/story/30-maicheng-isolation.png` | 2 | 1672x941 | 1.1976 | low PNG byte density | `fortyFourthBattleIntroPages/forty-fourth-lu-meng-seals-roads`
`fortyFourthBattleVictoryPages/forty-fourth-lu-meng-tightens` | ## Battle Map Inventory @@ -133,139 +133,139 @@ Generated by `scripts/audit-visual-asset-quality.mjs`. | source | page refs | image | MiB | concerns | | --- | ---: | ---: | ---: | --- | -| `src/assets/images/story/01-yellow-turban-chaos.png` | 1 | 1672x941 | 2.17 | none | -| `src/assets/images/story/02-liu-bei-resolve.png` | 1 | 1672x941 | 2.05 | none | -| `src/assets/images/story/03-three-heroes-meet.png` | 2 | 1672x941 | 2.21 | none | -| `src/assets/images/story/04-volunteer-militia.png` | 1 | 1672x941 | 2.26 | none | -| `src/assets/images/story/05-first-sortie.png` | 4 | 1672x941 | 2.39 | none | -| `src/assets/images/story/06-yellow-turban-pursuit.png` | 26 | 1672x941 | 2.38 | high-repeat story background | -| `src/assets/images/story/07-anti-dong-pass.png` | 12 | 1672x941 | 2.61 | high-repeat story background | -| `src/assets/images/story/08-xuzhou-handover.png` | 18 | 1672x941 | 2.06 | high-repeat story background | -| `src/assets/images/story/09-wandering-road.png` | 39 | 1672x941 | 2.01 | high-repeat story background | -| `src/assets/images/story/10-wolong-cottage.png` | 21 | 1672x941 | 2.54 | high-repeat story background | -| `src/assets/images/story/100-jieting-crisis-wooden-palisade.png` | 0 | 1672x941 | 2.81 | unused source background | -| `src/assets/images/story/101-chengdu-surrender-river-gate.png` | 0 | 1672x941 | 2.67 | unused source background | -| `src/assets/images/story/102-yiling-baidi-lantern-vigil.png` | 0 | 1672x941 | 2.26 | unused source background | -| `src/assets/images/story/103-weishui-northbank-frozen-ferry.png` | 0 | 1672x941 | 2.75 | unused source background | -| `src/assets/images/story/104-lucheng-pursuit-dust-road.png` | 0 | 1672x941 | 3.01 | unused source background | -| `src/assets/images/story/105-qishan-renewed-snow-relay.png` | 0 | 1672x941 | 2.81 | unused source background | -| `src/assets/images/story/106-hanzhong-rain-supply-trench.png` | 0 | 1672x941 | 2.49 | unused source background | -| `src/assets/images/story/107-maicheng-isolation-abandoned-gate.png` | 0 | 1672x941 | 2.45 | unused source background | -| `src/assets/images/story/108-yiling-fire-forest-corridor.png` | 0 | 1672x941 | 2.44 | unused source background | -| `src/assets/images/story/109-wudu-yinping-cliff-granary.png` | 0 | 1672x941 | 2.87 | unused source background | -| `src/assets/images/story/11-red-cliffs-fire.png` | 28 | 1672x941 | 2.28 | high-repeat story background | -| `src/assets/images/story/110-baidi-entrustment-medicine-chamber.png` | 0 | 1672x941 | 2.40 | unused source background | -| `src/assets/images/story/111-tianshui-front-market-command.png` | 0 | 1672x941 | 2.46 | unused source background | -| `src/assets/images/story/112-chencang-siege-ladder-yard.png` | 0 | 1672x941 | 2.65 | unused source background | -| `src/assets/images/story/113-anti-dong-coalition-oath-camp.png` | 0 | 1672x941 | 2.57 | unused source background | -| `src/assets/images/story/114-red-cliffs-wind-altar.png` | 0 | 1672x941 | 2.33 | unused source background | -| `src/assets/images/story/115-qishan-renewed-frozen-scout-line.png` | 0 | 1672x941 | 2.50 | unused source background | -| `src/assets/images/story/116-yiling-fire-attack-river-ashes.png` | 0 | 1672x941 | 2.26 | unused source background | -| `src/assets/images/story/117-baidi-entrustment-river-watch.png` | 0 | 1672x941 | 2.10 | unused source background | -| `src/assets/images/story/118-tianshui-front-hidden-letter.png` | 0 | 1672x941 | 2.35 | unused source background | -| `src/assets/images/story/119-hanzhong-rain-mountain-shrine.png` | 0 | 1672x941 | 2.67 | unused source background | -| `src/assets/images/story/12-yizhou-mountain-pass.png` | 29 | 1672x941 | 2.55 | high-repeat story background | -| `src/assets/images/story/120-maicheng-isolation-snow-bridge.png` | 0 | 1672x941 | 2.29 | unused source background | -| `src/assets/images/story/121-wudu-yinping-cloud-signal.png` | 0 | 1672x941 | 2.68 | unused source background | -| `src/assets/images/story/122-chencang-siege-broken-ram.png` | 0 | 1672x941 | 2.46 | unused source background | -| `src/assets/images/story/123-yellow-pursuit-grain-relief.png` | 0 | 1672x941 | 2.51 | unused source background | -| `src/assets/images/story/124-nanzhong-captures-mist-stockade.png` | 0 | 1672x941 | 2.48 | unused source background | -| `src/assets/images/story/125-chaos-market-panic.png` | 0 | 1672x941 | 2.57 | unused source background | -| `src/assets/images/story/126-militia-training-yard.png` | 0 | 1672x941 | 2.52 | unused source background | -| `src/assets/images/story/127-sortie-dawn-muster.png` | 0 | 1672x941 | 2.45 | unused source background | -| `src/assets/images/story/128-liu-bei-village-resolve.png` | 0 | 1672x941 | 2.27 | unused source background | -| `src/assets/images/story/129-three-heroes-rain-shelter.png` | 0 | 1672x941 | 2.31 | unused source background | -| `src/assets/images/story/13-northern-expedition.png` | 36 | 1672x941 | 2.35 | high-repeat story background | -| `src/assets/images/story/130-lucheng-pursuit-ravine-signal.png` | 0 | 1672x941 | 2.72 | unused source background | -| `src/assets/images/story/131-weishui-camps-frozen-watch.png` | 0 | 1672x941 | 2.22 | unused source background | -| `src/assets/images/story/132-weishui-northbank-siege-line.png` | 0 | 1672x941 | 2.55 | unused source background | -| `src/assets/images/story/133-yiling-fire-attack-smoke-crossing.png` | 0 | 1672x941 | 2.37 | unused source background | -| `src/assets/images/story/134-chengdu-surrender-sealed-gate.png` | 0 | 1672x941 | 2.50 | unused source background | +| `src/assets/images/story/01-yellow-turban-chaos.png` | 0 | 1672x941 | 2.17 | unused source background | +| `src/assets/images/story/02-liu-bei-resolve.png` | 0 | 1672x941 | 2.05 | unused source background | +| `src/assets/images/story/03-three-heroes-meet.png` | 1 | 1672x941 | 2.21 | none | +| `src/assets/images/story/04-volunteer-militia.png` | 0 | 1672x941 | 2.26 | unused source background | +| `src/assets/images/story/05-first-sortie.png` | 2 | 1672x941 | 2.39 | none | +| `src/assets/images/story/06-yellow-turban-pursuit.png` | 7 | 1672x941 | 2.38 | none | +| `src/assets/images/story/07-anti-dong-pass.png` | 3 | 1672x941 | 2.61 | none | +| `src/assets/images/story/08-xuzhou-handover.png` | 3 | 1672x941 | 2.06 | none | +| `src/assets/images/story/09-wandering-road.png` | 10 | 1672x941 | 2.01 | repeat story background | +| `src/assets/images/story/10-wolong-cottage.png` | 6 | 1672x941 | 2.54 | none | +| `src/assets/images/story/100-jieting-crisis-wooden-palisade.png` | 3 | 1672x941 | 2.81 | none | +| `src/assets/images/story/101-chengdu-surrender-river-gate.png` | 1 | 1672x941 | 2.67 | none | +| `src/assets/images/story/102-yiling-baidi-lantern-vigil.png` | 2 | 1672x941 | 2.26 | none | +| `src/assets/images/story/103-weishui-northbank-frozen-ferry.png` | 3 | 1672x941 | 2.75 | none | +| `src/assets/images/story/104-lucheng-pursuit-dust-road.png` | 1 | 1672x941 | 3.01 | none | +| `src/assets/images/story/105-qishan-renewed-snow-relay.png` | 2 | 1672x941 | 2.81 | none | +| `src/assets/images/story/106-hanzhong-rain-supply-trench.png` | 3 | 1672x941 | 2.49 | none | +| `src/assets/images/story/107-maicheng-isolation-abandoned-gate.png` | 1 | 1672x941 | 2.45 | none | +| `src/assets/images/story/108-yiling-fire-forest-corridor.png` | 2 | 1672x941 | 2.44 | none | +| `src/assets/images/story/109-wudu-yinping-cliff-granary.png` | 1 | 1672x941 | 2.87 | none | +| `src/assets/images/story/11-red-cliffs-fire.png` | 7 | 1672x941 | 2.28 | none | +| `src/assets/images/story/110-baidi-entrustment-medicine-chamber.png` | 2 | 1672x941 | 2.40 | none | +| `src/assets/images/story/111-tianshui-front-market-command.png` | 1 | 1672x941 | 2.46 | none | +| `src/assets/images/story/112-chencang-siege-ladder-yard.png` | 2 | 1672x941 | 2.65 | none | +| `src/assets/images/story/113-anti-dong-coalition-oath-camp.png` | 2 | 1672x941 | 2.57 | none | +| `src/assets/images/story/114-red-cliffs-wind-altar.png` | 4 | 1672x941 | 2.33 | none | +| `src/assets/images/story/115-qishan-renewed-frozen-scout-line.png` | 2 | 1672x941 | 2.50 | none | +| `src/assets/images/story/116-yiling-fire-attack-river-ashes.png` | 2 | 1672x941 | 2.26 | none | +| `src/assets/images/story/117-baidi-entrustment-river-watch.png` | 1 | 1672x941 | 2.10 | none | +| `src/assets/images/story/118-tianshui-front-hidden-letter.png` | 2 | 1672x941 | 2.35 | none | +| `src/assets/images/story/119-hanzhong-rain-mountain-shrine.png` | 1 | 1672x941 | 2.67 | none | +| `src/assets/images/story/12-yizhou-mountain-pass.png` | 8 | 1672x941 | 2.55 | repeat story background | +| `src/assets/images/story/120-maicheng-isolation-snow-bridge.png` | 3 | 1672x941 | 2.29 | none | +| `src/assets/images/story/121-wudu-yinping-cloud-signal.png` | 3 | 1672x941 | 2.68 | none | +| `src/assets/images/story/122-chencang-siege-broken-ram.png` | 1 | 1672x941 | 2.46 | none | +| `src/assets/images/story/123-yellow-pursuit-grain-relief.png` | 4 | 1672x941 | 2.51 | none | +| `src/assets/images/story/124-nanzhong-captures-mist-stockade.png` | 7 | 1672x941 | 2.48 | none | +| `src/assets/images/story/125-chaos-market-panic.png` | 1 | 1672x941 | 2.57 | none | +| `src/assets/images/story/126-militia-training-yard.png` | 1 | 1672x941 | 2.52 | none | +| `src/assets/images/story/127-sortie-dawn-muster.png` | 2 | 1672x941 | 2.45 | none | +| `src/assets/images/story/128-liu-bei-village-resolve.png` | 1 | 1672x941 | 2.27 | none | +| `src/assets/images/story/129-three-heroes-rain-shelter.png` | 1 | 1672x941 | 2.31 | none | +| `src/assets/images/story/13-northern-expedition.png` | 11 | 1672x941 | 2.35 | repeat story background | +| `src/assets/images/story/130-lucheng-pursuit-ravine-signal.png` | 1 | 1672x941 | 2.72 | none | +| `src/assets/images/story/131-weishui-camps-frozen-watch.png` | 2 | 1672x941 | 2.22 | none | +| `src/assets/images/story/132-weishui-northbank-siege-line.png` | 1 | 1672x941 | 2.55 | none | +| `src/assets/images/story/133-yiling-fire-attack-smoke-crossing.png` | 2 | 1672x941 | 2.37 | none | +| `src/assets/images/story/134-chengdu-surrender-sealed-gate.png` | 1 | 1672x941 | 2.50 | none | | `src/assets/images/story/135-wuzhang-jiangwei-inheritance.png` | 2 | 1672x941 | 2.09 | none | -| `src/assets/images/story/14-nanzhong-pacification.png` | 7 | 1672x941 | 2.41 | none | -| `src/assets/images/story/15-yiling-baidi-aftermath.png` | 8 | 1672x941 | 2.20 | repeat story background | -| `src/assets/images/story/16-qishan-renewed-offensive.png` | 6 | 1672x941 | 2.26 | none | -| `src/assets/images/story/17-tianshui-front.png` | 5 | 1672x941 | 2.12 | none | -| `src/assets/images/story/18-jieting-crisis.png` | 13 | 1672x941 | 2.30 | high-repeat story background | -| `src/assets/images/story/19-chencang-siege.png` | 6 | 1672x941 | 2.38 | none | -| `src/assets/images/story/20-wudu-yinping.png` | 6 | 1672x941 | 2.55 | none | -| `src/assets/images/story/21-hanzhong-rain-defense.png` | 9 | 1672x941 | 2.25 | repeat story background | -| `src/assets/images/story/22-lucheng-pursuit.png` | 6 | 1672x941 | 2.03 | none | -| `src/assets/images/story/23-weishui-camps.png` | 6 | 1672x941 | 2.31 | none | -| `src/assets/images/story/24-weishui-northbank.png` | 8 | 1672x941 | 2.22 | repeat story background | -| `src/assets/images/story/25-yizhou-luo-road.png` | 49 | 1672x941 | 3.60 | high-repeat story background | -| `src/assets/images/story/26-chengdu-surrender.png` | 11 | 1672x941 | 3.64 | repeat story background | -| `src/assets/images/story/27-jingzhou-crisis.png` | 21 | 1672x941 | 3.23 | high-repeat story background | -| `src/assets/images/story/28-nanzhong-seven-captures.png` | 42 | 1672x941 | 3.62 | high-repeat story background | -| `src/assets/images/story/29-fan-castle-flood.png` | 21 | 1672x941 | 2.26 | high-repeat story background | -| `src/assets/images/story/30-maicheng-isolation.png` | 7 | 1672x941 | 1.80 | low PNG byte density | -| `src/assets/images/story/31-yiling-fire-attack.png` | 7 | 1672x941 | 2.10 | none | -| `src/assets/images/story/32-baidi-entrustment.png` | 5 | 1672x941 | 1.96 | none | -| `src/assets/images/story/33-yellow-pursuit-refugees.png` | 0 | 1672x941 | 2.41 | unused source background | -| `src/assets/images/story/34-xuzhou-gates-handover.png` | 0 | 1672x941 | 2.24 | unused source background | -| `src/assets/images/story/35-wolong-night-strategy.png` | 0 | 1672x941 | 2.12 | unused source background | -| `src/assets/images/story/36-red-cliffs-chain-fleet.png` | 0 | 1672x941 | 2.13 | unused source background | -| `src/assets/images/story/37-yizhou-luo-road-ambush.png` | 0 | 1672x941 | 2.60 | unused source background | -| `src/assets/images/story/38-yizhou-luo-road-supply-train.png` | 0 | 1672x941 | 2.96 | unused source background | -| `src/assets/images/story/39-nanzhong-captures-jungle-net.png` | 0 | 1672x941 | 3.12 | unused source background | -| `src/assets/images/story/40-nanzhong-captures-river-ford.png` | 0 | 1672x941 | 2.73 | unused source background | -| `src/assets/images/story/41-wandering-night-camp.png` | 0 | 1672x941 | 2.55 | unused source background | -| `src/assets/images/story/42-wandering-river-crossing.png` | 0 | 1672x941 | 2.60 | unused source background | -| `src/assets/images/story/43-northern-qishan-snow-march.png` | 0 | 1672x941 | 3.03 | unused source background | -| `src/assets/images/story/44-northern-wei-fortress.png` | 0 | 1672x941 | 2.93 | unused source background | -| `src/assets/images/story/45-yizhou-council-at-river.png` | 0 | 1672x941 | 2.89 | unused source background | -| `src/assets/images/story/46-yizhou-mountain-gate.png` | 0 | 1672x941 | 3.01 | unused source background | -| `src/assets/images/story/47-jingzhou-crisis-watchtower-alarm.png` | 0 | 1672x941 | 2.81 | unused source background | -| `src/assets/images/story/48-jingzhou-crisis-refugee-court.png` | 0 | 1672x941 | 2.78 | unused source background | -| `src/assets/images/story/49-fan-castle-flood-rising-water.png` | 0 | 1672x941 | 2.62 | unused source background | -| `src/assets/images/story/50-fan-castle-flood-siege-engines.png` | 0 | 1672x941 | 2.71 | unused source background | -| `src/assets/images/story/51-jieting-crisis-supply-line.png` | 0 | 1672x941 | 3.08 | unused source background | -| `src/assets/images/story/52-jieting-crisis-mountain-camp.png` | 0 | 1672x941 | 2.64 | unused source background | -| `src/assets/images/story/53-chengdu-surrender-hall.png` | 0 | 1672x941 | 2.96 | unused source background | -| `src/assets/images/story/54-anti-dong-hulao-pass.png` | 0 | 1672x941 | 2.85 | unused source background | -| `src/assets/images/story/55-yellow-pursuit-ambush.png` | 0 | 1672x941 | 2.65 | unused source background | -| `src/assets/images/story/56-yellow-pursuit-village-relief.png` | 0 | 1672x941 | 3.07 | unused source background | -| `src/assets/images/story/57-wolong-straw-boats.png` | 0 | 1672x941 | 2.40 | unused source background | -| `src/assets/images/story/58-red-cliffs-wind-prayer.png` | 0 | 1672x941 | 2.78 | unused source background | -| `src/assets/images/story/59-yiling-baidi-mountain-retreat.png` | 0 | 1672x941 | 2.84 | unused source background | -| `src/assets/images/story/60-maicheng-isolation-snow-road.png` | 0 | 1672x941 | 2.74 | unused source background | -| `src/assets/images/story/61-yiling-fire-attack-riverbank.png` | 0 | 1672x941 | 2.60 | unused source background | -| `src/assets/images/story/62-hanzhong-rain-night-watch.png` | 0 | 1672x941 | 2.55 | unused source background | -| `src/assets/images/story/63-weishui-northbank-cavalry-screen.png` | 0 | 1672x941 | 2.74 | unused source background | -| `src/assets/images/story/64-nanzhong-forest-council.png` | 0 | 1672x941 | 2.73 | unused source background | -| `src/assets/images/story/65-yizhou-luo-road-cliff-fortress.png` | 0 | 1672x941 | 2.36 | unused source background | -| `src/assets/images/story/66-yizhou-luo-road-mountain-scouts.png` | 0 | 1672x941 | 2.59 | unused source background | -| `src/assets/images/story/67-nanzhong-captures-bamboo-gorge.png` | 0 | 1672x941 | 2.46 | unused source background | -| `src/assets/images/story/68-nanzhong-captures-river-night-camp.png` | 0 | 1672x941 | 2.29 | unused source background | -| `src/assets/images/story/69-wandering-market-shelter.png` | 0 | 1672x941 | 2.31 | unused source background | -| `src/assets/images/story/70-wandering-cold-mountain-pass.png` | 0 | 1672x941 | 2.10 | unused source background | -| `src/assets/images/story/71-northern-supply-valley.png` | 0 | 1672x941 | 2.65 | unused source background | -| `src/assets/images/story/72-northern-mountain-war-council.png` | 0 | 1672x941 | 2.05 | unused source background | -| `src/assets/images/story/73-red-cliffs-chain-smoke.png` | 0 | 1672x941 | 2.20 | unused source background | -| `src/assets/images/story/74-wolong-river-strategy.png` | 0 | 1672x941 | 2.29 | unused source background | -| `src/assets/images/story/75-jingzhou-crisis-river-watchtowers.png` | 0 | 1672x941 | 2.38 | unused source background | -| `src/assets/images/story/76-fan-castle-flood-river-breach.png` | 0 | 1672x941 | 2.45 | unused source background | -| `src/assets/images/story/77-xuzhou-gate-council.png` | 0 | 1672x941 | 2.49 | unused source background | -| `src/assets/images/story/78-xuzhou-night-evacuation.png` | 0 | 1672x941 | 2.33 | unused source background | -| `src/assets/images/story/79-anti-dong-coalition-council.png` | 0 | 1672x941 | 2.62 | unused source background | -| `src/assets/images/story/80-anti-dong-night-assault.png` | 0 | 1672x941 | 2.37 | unused source background | -| `src/assets/images/story/81-chengdu-surrender-court.png` | 0 | 1672x941 | 2.50 | unused source background | -| `src/assets/images/story/82-chengdu-outer-gate-surrender.png` | 0 | 1672x941 | 2.73 | unused source background | -| `src/assets/images/story/83-wudu-yinping-cloud-road.png` | 0 | 1672x941 | 2.66 | unused source background | -| `src/assets/images/story/84-chencang-siege-fortress-standoff.png` | 0 | 1672x941 | 2.84 | unused source background | -| `src/assets/images/story/85-chencang-siege-engineering-camp.png` | 0 | 1672x941 | 2.49 | unused source background | -| `src/assets/images/story/86-weishui-camps-logistics.png` | 0 | 1672x941 | 2.53 | unused source background | -| `src/assets/images/story/87-weishui-camps-night-alarm.png` | 0 | 1672x941 | 2.40 | unused source background | -| `src/assets/images/story/88-lucheng-pursuit-valley-road.png` | 0 | 1672x941 | 2.59 | unused source background | -| `src/assets/images/story/89-yizhou-luo-road-hidden-bridge.png` | 0 | 1672x941 | 2.42 | unused source background | -| `src/assets/images/story/90-nanzhong-captures-monsoon-ravine.png` | 0 | 1672x941 | 2.80 | unused source background | -| `src/assets/images/story/91-wandering-snow-shrine.png` | 0 | 1672x941 | 2.59 | unused source background | -| `src/assets/images/story/92-northern-supply-depot.png` | 0 | 1672x941 | 2.73 | unused source background | -| `src/assets/images/story/93-yizhou-mountain-pass-command-post.png` | 0 | 1672x941 | 2.70 | unused source background | -| `src/assets/images/story/94-red-cliffs-dawn-aftermath.png` | 0 | 1672x941 | 2.43 | unused source background | -| `src/assets/images/story/95-yellow-pursuit-rural-crossroads.png` | 0 | 1672x941 | 2.95 | unused source background | -| `src/assets/images/story/96-wolong-autumn-study.png` | 0 | 1672x941 | 2.56 | unused source background | -| `src/assets/images/story/97-fan-castle-flood-prison-camp.png` | 0 | 1672x941 | 2.15 | unused source background | -| `src/assets/images/story/98-jingzhou-crisis-secret-letter.png` | 0 | 1672x941 | 2.22 | unused source background | -| `src/assets/images/story/99-xuzhou-flooded-courtyard.png` | 0 | 1672x941 | 2.69 | unused source background | +| `src/assets/images/story/14-nanzhong-pacification.png` | 1 | 1672x941 | 2.41 | none | +| `src/assets/images/story/15-yiling-baidi-aftermath.png` | 3 | 1672x941 | 2.20 | none | +| `src/assets/images/story/16-qishan-renewed-offensive.png` | 2 | 1672x941 | 2.26 | none | +| `src/assets/images/story/17-tianshui-front.png` | 2 | 1672x941 | 2.12 | none | +| `src/assets/images/story/18-jieting-crisis.png` | 3 | 1672x941 | 2.30 | none | +| `src/assets/images/story/19-chencang-siege.png` | 0 | 1672x941 | 2.38 | unused source background | +| `src/assets/images/story/20-wudu-yinping.png` | 1 | 1672x941 | 2.55 | none | +| `src/assets/images/story/21-hanzhong-rain-defense.png` | 2 | 1672x941 | 2.25 | none | +| `src/assets/images/story/22-lucheng-pursuit.png` | 2 | 1672x941 | 2.03 | none | +| `src/assets/images/story/23-weishui-camps.png` | 1 | 1672x941 | 2.31 | none | +| `src/assets/images/story/24-weishui-northbank.png` | 2 | 1672x941 | 2.22 | none | +| `src/assets/images/story/25-yizhou-luo-road.png` | 10 | 1672x941 | 3.60 | repeat story background | +| `src/assets/images/story/26-chengdu-surrender.png` | 2 | 1672x941 | 3.64 | none | +| `src/assets/images/story/27-jingzhou-crisis.png` | 5 | 1672x941 | 3.23 | none | +| `src/assets/images/story/28-nanzhong-seven-captures.png` | 8 | 1672x941 | 3.62 | repeat story background | +| `src/assets/images/story/29-fan-castle-flood.png` | 7 | 1672x941 | 2.26 | none | +| `src/assets/images/story/30-maicheng-isolation.png` | 2 | 1672x941 | 1.80 | low PNG byte density | +| `src/assets/images/story/31-yiling-fire-attack.png` | 0 | 1672x941 | 2.10 | unused source background | +| `src/assets/images/story/32-baidi-entrustment.png` | 2 | 1672x941 | 1.96 | none | +| `src/assets/images/story/33-yellow-pursuit-refugees.png` | 6 | 1672x941 | 2.41 | none | +| `src/assets/images/story/34-xuzhou-gates-handover.png` | 1 | 1672x941 | 2.24 | none | +| `src/assets/images/story/35-wolong-night-strategy.png` | 5 | 1672x941 | 2.12 | none | +| `src/assets/images/story/36-red-cliffs-chain-fleet.png` | 5 | 1672x941 | 2.13 | none | +| `src/assets/images/story/37-yizhou-luo-road-ambush.png` | 9 | 1672x941 | 2.60 | repeat story background | +| `src/assets/images/story/38-yizhou-luo-road-supply-train.png` | 14 | 1672x941 | 2.96 | high-repeat story background | +| `src/assets/images/story/39-nanzhong-captures-jungle-net.png` | 5 | 1672x941 | 3.12 | none | +| `src/assets/images/story/40-nanzhong-captures-river-ford.png` | 10 | 1672x941 | 2.73 | repeat story background | +| `src/assets/images/story/41-wandering-night-camp.png` | 5 | 1672x941 | 2.55 | none | +| `src/assets/images/story/42-wandering-river-crossing.png` | 6 | 1672x941 | 2.60 | none | +| `src/assets/images/story/43-northern-qishan-snow-march.png` | 5 | 1672x941 | 3.03 | none | +| `src/assets/images/story/44-northern-wei-fortress.png` | 5 | 1672x941 | 2.93 | none | +| `src/assets/images/story/45-yizhou-council-at-river.png` | 2 | 1672x941 | 2.89 | none | +| `src/assets/images/story/46-yizhou-mountain-gate.png` | 2 | 1672x941 | 3.01 | none | +| `src/assets/images/story/47-jingzhou-crisis-watchtower-alarm.png` | 7 | 1672x941 | 2.81 | none | +| `src/assets/images/story/48-jingzhou-crisis-refugee-court.png` | 3 | 1672x941 | 2.78 | none | +| `src/assets/images/story/49-fan-castle-flood-rising-water.png` | 4 | 1672x941 | 2.62 | none | +| `src/assets/images/story/50-fan-castle-flood-siege-engines.png` | 3 | 1672x941 | 2.71 | none | +| `src/assets/images/story/51-jieting-crisis-supply-line.png` | 3 | 1672x941 | 3.08 | none | +| `src/assets/images/story/52-jieting-crisis-mountain-camp.png` | 4 | 1672x941 | 2.64 | none | +| `src/assets/images/story/53-chengdu-surrender-hall.png` | 1 | 1672x941 | 2.96 | none | +| `src/assets/images/story/54-anti-dong-hulao-pass.png` | 1 | 1672x941 | 2.85 | none | +| `src/assets/images/story/55-yellow-pursuit-ambush.png` | 5 | 1672x941 | 2.65 | none | +| `src/assets/images/story/56-yellow-pursuit-village-relief.png` | 2 | 1672x941 | 3.07 | none | +| `src/assets/images/story/57-wolong-straw-boats.png` | 2 | 1672x941 | 2.40 | none | +| `src/assets/images/story/58-red-cliffs-wind-prayer.png` | 6 | 1672x941 | 2.78 | none | +| `src/assets/images/story/59-yiling-baidi-mountain-retreat.png` | 3 | 1672x941 | 2.84 | none | +| `src/assets/images/story/60-maicheng-isolation-snow-road.png` | 1 | 1672x941 | 2.74 | none | +| `src/assets/images/story/61-yiling-fire-attack-riverbank.png` | 1 | 1672x941 | 2.60 | none | +| `src/assets/images/story/62-hanzhong-rain-night-watch.png` | 3 | 1672x941 | 2.55 | none | +| `src/assets/images/story/63-weishui-northbank-cavalry-screen.png` | 2 | 1672x941 | 2.74 | none | +| `src/assets/images/story/64-nanzhong-forest-council.png` | 1 | 1672x941 | 2.73 | none | +| `src/assets/images/story/65-yizhou-luo-road-cliff-fortress.png` | 5 | 1672x941 | 2.36 | none | +| `src/assets/images/story/66-yizhou-luo-road-mountain-scouts.png` | 11 | 1672x941 | 2.59 | repeat story background | +| `src/assets/images/story/67-nanzhong-captures-bamboo-gorge.png` | 6 | 1672x941 | 2.46 | none | +| `src/assets/images/story/68-nanzhong-captures-river-night-camp.png` | 5 | 1672x941 | 2.29 | none | +| `src/assets/images/story/69-wandering-market-shelter.png` | 6 | 1672x941 | 2.31 | none | +| `src/assets/images/story/70-wandering-cold-mountain-pass.png` | 7 | 1672x941 | 2.10 | none | +| `src/assets/images/story/71-northern-supply-valley.png` | 3 | 1672x941 | 2.65 | none | +| `src/assets/images/story/72-northern-mountain-war-council.png` | 8 | 1672x941 | 2.05 | repeat story background | +| `src/assets/images/story/73-red-cliffs-chain-smoke.png` | 3 | 1672x941 | 2.20 | none | +| `src/assets/images/story/74-wolong-river-strategy.png` | 2 | 1672x941 | 2.29 | none | +| `src/assets/images/story/75-jingzhou-crisis-river-watchtowers.png` | 2 | 1672x941 | 2.38 | none | +| `src/assets/images/story/76-fan-castle-flood-river-breach.png` | 3 | 1672x941 | 2.45 | none | +| `src/assets/images/story/77-xuzhou-gate-council.png` | 6 | 1672x941 | 2.49 | none | +| `src/assets/images/story/78-xuzhou-night-evacuation.png` | 4 | 1672x941 | 2.33 | none | +| `src/assets/images/story/79-anti-dong-coalition-council.png` | 3 | 1672x941 | 2.62 | none | +| `src/assets/images/story/80-anti-dong-night-assault.png` | 3 | 1672x941 | 2.37 | none | +| `src/assets/images/story/81-chengdu-surrender-court.png` | 3 | 1672x941 | 2.50 | none | +| `src/assets/images/story/82-chengdu-outer-gate-surrender.png` | 3 | 1672x941 | 2.73 | none | +| `src/assets/images/story/83-wudu-yinping-cloud-road.png` | 1 | 1672x941 | 2.66 | none | +| `src/assets/images/story/84-chencang-siege-fortress-standoff.png` | 2 | 1672x941 | 2.84 | none | +| `src/assets/images/story/85-chencang-siege-engineering-camp.png` | 1 | 1672x941 | 2.49 | none | +| `src/assets/images/story/86-weishui-camps-logistics.png` | 2 | 1672x941 | 2.53 | none | +| `src/assets/images/story/87-weishui-camps-night-alarm.png` | 1 | 1672x941 | 2.40 | none | +| `src/assets/images/story/88-lucheng-pursuit-valley-road.png` | 2 | 1672x941 | 2.59 | none | +| `src/assets/images/story/89-yizhou-luo-road-hidden-bridge.png` | 13 | 1672x941 | 2.42 | high-repeat story background | +| `src/assets/images/story/90-nanzhong-captures-monsoon-ravine.png` | 6 | 1672x941 | 2.80 | none | +| `src/assets/images/story/91-wandering-snow-shrine.png` | 5 | 1672x941 | 2.59 | none | +| `src/assets/images/story/92-northern-supply-depot.png` | 4 | 1672x941 | 2.73 | none | +| `src/assets/images/story/93-yizhou-mountain-pass-command-post.png` | 4 | 1672x941 | 2.70 | none | +| `src/assets/images/story/94-red-cliffs-dawn-aftermath.png` | 3 | 1672x941 | 2.43 | none | +| `src/assets/images/story/95-yellow-pursuit-rural-crossroads.png` | 2 | 1672x941 | 2.95 | none | +| `src/assets/images/story/96-wolong-autumn-study.png` | 6 | 1672x941 | 2.56 | none | +| `src/assets/images/story/97-fan-castle-flood-prison-camp.png` | 4 | 1672x941 | 2.15 | none | +| `src/assets/images/story/98-jingzhou-crisis-secret-letter.png` | 4 | 1672x941 | 2.22 | none | +| `src/assets/images/story/99-xuzhou-flooded-courtyard.png` | 4 | 1672x941 | 2.69 | none | | `src/assets/images/taoyuan-oath-title.png` | 3 | 1672x941 | 2.24 | none | diff --git a/package.json b/package.json index 00772fd..9a0ceb8 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "verify:enemy-intent": "node scripts/verify-enemy-intent-rollout.mjs", "verify:sortie-synergy": "node scripts/verify-sortie-synergy.mjs", "verify:sortie-recommendations": "node scripts/verify-sortie-recommendations.mjs", + "verify:sortie-roster-history": "node scripts/verify-sortie-roster-history.mjs", "verify:camp-rewards": "node scripts/verify-camp-reward-data.mjs", "verify:camp-skins": "node scripts/verify-camp-skin-data.mjs", "verify:camp-soundscapes": "node scripts/verify-camp-soundscape-data.mjs", diff --git a/scripts/audit-visual-asset-quality.mjs b/scripts/audit-visual-asset-quality.mjs index a4ae3b6..1b090d1 100644 --- a/scripts/audit-visual-asset-quality.mjs +++ b/scripts/audit-visual-asset-quality.mjs @@ -19,12 +19,12 @@ const server = await createServer({ try { const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); const { battleMapAssets } = await server.ssrLoadModule('/src/game/data/battleMapAssets.ts'); - const { storyBackgroundAssets, storyBackgroundKeysFor } = await server.ssrLoadModule('/src/game/data/storyAssets.ts'); + const { storyBackgroundAssets, storyBackgroundKeysForPages } = await server.ssrLoadModule('/src/game/data/storyAssets.ts'); const scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts'); const reviewedBattleMapPrefixes = collectReviewedBattleMapPrefixes(); const battleRows = collectBattleRows(battleScenarios, battleMapAssets, reviewedBattleMapPrefixes); - const storyUsage = collectStoryUsage(scenarioModule, storyBackgroundAssets, storyBackgroundKeysFor); + const storyUsage = collectStoryUsage(scenarioModule, storyBackgroundAssets, storyBackgroundKeysForPages); const storyRows = collectStoryRows(storyBackgroundAssets, storyUsage); const report = renderReport({ battleRows, storyRows, storyUsage }); @@ -122,7 +122,7 @@ function battleOrdinalPrefix(battleId) { return battleId.match(/^(.+)-battle-/)?.[1] ?? battleId; } -function collectStoryUsage(scenarioModule, storyBackgroundAssets, storyBackgroundKeysFor) { +function collectStoryUsage(scenarioModule, storyBackgroundAssets, storyBackgroundKeysForPages) { const usageByPath = new Map(); const pageReferences = []; @@ -131,28 +131,30 @@ function collectStoryUsage(scenarioModule, storyBackgroundAssets, storyBackgroun return; } - value - .filter((entry) => entry && typeof entry === 'object' && typeof entry.background === 'string') - .forEach((page) => { - const candidateKeys = storyBackgroundKeysFor(page.background); - const resolvedKey = candidateKeys.find((key) => storyBackgroundAssets[key]) ?? page.background; - const path = assetUrlToPath(storyBackgroundAssets[resolvedKey], 'src/assets/images/'); - const usage = usageByPath.get(path) ?? { - path, - keys: new Set(), - baseBackgrounds: new Set(), - pageCount: 0, - pageExamples: [] - }; - usage.keys.add(resolvedKey); - usage.baseBackgrounds.add(page.background); - usage.pageCount += 1; - if (usage.pageExamples.length < 3) { - usage.pageExamples.push(`${exportName}/${page.id ?? 'unnamed'}`); - } - usageByPath.set(path, usage); - pageReferences.push({ exportName, pageId: page.id, background: page.background, resolvedKey, path }); - }); + const pages = value.filter( + (entry) => entry && typeof entry === 'object' && typeof entry.id === 'string' && typeof entry.background === 'string' + ); + const selectedKeys = storyBackgroundKeysForPages(pages); + + pages.forEach((page, index) => { + const resolvedKey = selectedKeys[index] ?? page.background; + const path = assetUrlToPath(storyBackgroundAssets[resolvedKey], 'src/assets/images/'); + const usage = usageByPath.get(path) ?? { + path, + keys: new Set(), + baseBackgrounds: new Set(), + pageCount: 0, + pageExamples: [] + }; + usage.keys.add(resolvedKey); + usage.baseBackgrounds.add(page.background); + usage.pageCount += 1; + if (usage.pageExamples.length < 3) { + usage.pageExamples.push(`${exportName}/${page.id ?? 'unnamed'}`); + } + usageByPath.set(path, usage); + pageReferences.push({ exportName, pageId: page.id, background: page.background, resolvedKey, path }); + }); }); return { diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 09d4cae..3db4174 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -2401,13 +2401,28 @@ try { firstArrivalReward.cards.length === 6 && firstArrivalReward.cards.find((card) => card.id === 'gold')?.items.includes(`+${expectedFirstDisplayedRewardGold} 군자금`) && firstArrivalReward.cards.find((card) => card.id === 'supplies')?.items.includes('상처약 1') && + firstArrivalReward.cards.every((card) => card.iconReady === true) && + firstArrivalReward.cards.find((card) => card.id === 'equipment')?.iconKind === 'item' && + firstArrivalReward.cards.find((card) => card.id === 'equipment')?.new === true && + firstArrivalReward.cards.find((card) => card.id === 'equipment')?.actionId === 'equipment' && + firstArrivalReward.cards.find((card) => card.id === 'equipment')?.interactive === true && + firstArrivalReward.cards.find((card) => card.id === 'recruits')?.iconKind === 'portrait' && + firstArrivalReward.cards.find((card) => card.id === 'recruits')?.new === true && + firstArrivalReward.cards.find((card) => card.id === 'recruits')?.actionId === 'sortie' && + firstArrivalReward.cards.find((card) => card.id === 'unlocks')?.new === true && + firstArrivalReward.cards.find((card) => card.id === 'unlocks')?.actionId === 'sortie' && firstArrivalReward.cards.every((card) => boundsWithinFhdViewport(card.bounds)) && firstArrivalReward.actions.map((action) => action.id).join(',') === 'equipment,supplies,sortie,close' && firstArrivalReward.actions.every((action) => action.interactive && boundsWithinFhdViewport(action.bounds)) && + firstCampProbe.state?.sortieRoster + ?.filter((unit) => ['guan-yu', 'zhang-fei', 'jian-yong'].includes(unit.id)).length === 3 && + firstCampProbe.state?.sortieRoster + ?.filter((unit) => ['guan-yu', 'zhang-fei', 'jian-yong'].includes(unit.id)) + .every((unit) => unit.serviceHistory?.recentRecruit === true && unit.serviceHistory?.badge?.kind === 'recent-recruit') && firstCampProbe.state?.campTabs?.filter((tab) => ['supplies', 'equipment'].includes(tab.id)).every((tab) => tab.newBadgeVisible === true), `Expected first camp arrival to expose bounded reward cards, deep links, and NEW badges: ${JSON.stringify(firstArrivalReward)}` ); - const firstArrivalEquipmentPoint = await readCampVictoryRewardControlPoint(page, 'equipment'); + const firstArrivalEquipmentPoint = await readCampVictoryRewardCardControlPoint(page, 'equipment'); await page.mouse.click(firstArrivalEquipmentPoint.x, firstArrivalEquipmentPoint.y); await page.waitForFunction(() => { const camp = window.__HEROS_DEBUG__?.camp(); @@ -9106,6 +9121,34 @@ async function readCampVictoryRewardControlPoint(page, actionId) { return probe; } +async function readCampVictoryRewardCardControlPoint(page, cardId) { + const probe = await page.evaluate((requestedCardId) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const canvas = document.querySelector('canvas'); + const acknowledgement = window.__HEROS_DEBUG__?.camp?.()?.victoryRewardAcknowledgement; + const card = acknowledgement?.cards?.find((candidate) => candidate.id === requestedCardId); + const bounds = card?.bounds; + if (!scene || !canvas || !bounds || !card?.interactive) { + return { ready: false, cardId: requestedCardId, acknowledgement, bounds: bounds ?? null }; + } + const canvasBounds = canvas.getBoundingClientRect(); + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + return { + ready: true, + cardId: requestedCardId, + bounds, + x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, + y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height + }; + }, cardId); + assert( + probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), + `Expected an interactive camp victory-reward ${cardId} card: ${JSON.stringify(probe)}` + ); + return probe; +} + async function waitForCampAfterBattleResult(page) { await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; diff --git a/scripts/verify-sortie-roster-history.mjs b/scripts/verify-sortie-roster-history.mjs new file mode 100644 index 0000000..4399126 --- /dev/null +++ b/scripts/verify-sortie-roster-history.mjs @@ -0,0 +1,124 @@ +import { createServer } from 'vite'; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true, hmr: false }, + appType: 'custom' +}); + +try { + const { + defaultLongWaitBattleThreshold, + defaultRecentRecruitBattleWindow, + deriveSortieRosterHistory, + deriveSortieRosterHistoryByUnitId + } = await server.ssrLoadModule('/src/game/data/sortieRosterHistory.ts'); + + assert(defaultRecentRecruitBattleWindow === 1, 'Recent-recruit window must retain its one-battle default.'); + assert(defaultLongWaitBattleThreshold === 3, 'Long-wait threshold must retain its three-battle default.'); + + const empty = deriveSortieRosterHistory(['liu-bei'], {}); + assert(empty.length === 1, `Empty history must still return the requested roster unit: ${JSON.stringify(empty)}`); + assert( + empty[0].sortieCount === 0 && + empty[0].firstSortiePending === true && + empty[0].battlesSinceLastSortie === null && + empty[0].recentRecruit === false && + empty[0].badge.kind === 'first-sortie' && + empty[0].badge.label === '미출전', + `Empty history must produce a stable first-sortie state: ${JSON.stringify(empty[0])}` + ); + + const firstSortieHistory = { + opening: battle('opening', 1, ['guan-yu', 'guan-yu']) + }; + const frozenFirstSortieHistory = JSON.stringify(firstSortieHistory); + const firstSortie = deriveSortieRosterHistory(['guan-yu'], firstSortieHistory)[0]; + assert( + firstSortie.sortieCount === 1 && + firstSortie.firstSortiePending === false && + firstSortie.battlesSinceLastSortie === 0 && + firstSortie.lastSortieBattleId === 'opening' && + firstSortie.badge.kind === 'active' && + firstSortie.badge.label === '출전 1회', + `A first completed sortie must become the active state exactly once: ${JSON.stringify(firstSortie)}` + ); + assert( + JSON.stringify(firstSortieHistory) === frozenFirstSortieHistory, + 'History derivation must not mutate campaign battle-history-like input.' + ); + + const longWait = deriveSortieRosterHistory(['zhang-fei'], { + fourth: battle('fourth', 4), + first: battle('first', 1, ['zhang-fei']), + third: battle('third', 3), + second: battle('second', 2) + })[0]; + assert( + longWait.sortieCount === 1 && + longWait.battlesSinceLastSortie === 3 && + longWait.lastSortieBattleId === 'first' && + longWait.badge.kind === 'long-wait' && + longWait.badge.label === '대기 3전', + `Three later battles must produce a long-wait badge even when record insertion is unordered: ${JSON.stringify(longWait)}` + ); + + const recentRecruit = deriveSortieRosterHistory(['jian-yong'], { + recruitment: battle('recruitment', 1, [], ['jian-yong']) + })[0]; + assert( + recentRecruit.sortieCount === 0 && + recentRecruit.firstSortiePending === true && + recentRecruit.recentRecruit === true && + recentRecruit.battlesSinceRecruit === 0 && + recentRecruit.recruitBattleId === 'recruitment' && + recentRecruit.badge.kind === 'recent-recruit' && + recentRecruit.badge.label === 'NEW' && + recentRecruit.summary === '최근 합류 · 첫 출전 대기', + `A latest-battle recruit must expose the concise NEW state: ${JSON.stringify(recentRecruit)}` + ); + + const experiencedRecentRecruit = deriveSortieRosterHistory(['guan-yu'], { + recruitment: battle('recruitment', 1, ['guan-yu'], ['guan-yu']) + })[0]; + assert( + experiencedRecentRecruit.sortieCount === 1 && + experiencedRecentRecruit.firstSortiePending === false && + experiencedRecentRecruit.recentRecruit === true && + experiencedRecentRecruit.badge.kind === 'recent-recruit' && + experiencedRecentRecruit.badge.label === 'NEW', + `A recruit who fought before formally joining must retain the NEW roster badge: ${JSON.stringify(experiencedRecentRecruit)}` + ); + + const byUnitId = deriveSortieRosterHistoryByUnitId( + ['jian-yong', 'liu-bei', 'jian-yong', ' '], + [battle('recruitment', 1, ['liu-bei'], ['jian-yong'])] + ); + assert( + byUnitId.size === 2 && + byUnitId.get('jian-yong')?.recentRecruit === true && + byUnitId.get('liu-bei')?.sortieCount === 1, + `Map derivation must preserve unique roster entries and their states: ${JSON.stringify([...byUnitId])}` + ); + + console.log('Verified sortie roster history: empty, first sortie, long wait, recent recruit, ordering, and immutability.'); +} finally { + await server.close(); +} + +function battle(battleId, day, selectedUnitIds = [], recruitUnitIds = []) { + return { + battleId, + completedAt: new Date(Date.UTC(2026, 0, day)).toISOString(), + sortiePerformance: { selectedUnitIds }, + campaignRewards: { + recruits: recruitUnitIds.map((unitId) => ({ unitId })) + } + }; +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index c56f09e..c016ec2 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -10,6 +10,7 @@ const checks = [ '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-camp-skin-data.mjs', diff --git a/scripts/verify-story-asset-data.mjs b/scripts/verify-story-asset-data.mjs index 12b0edf..78c1c41 100644 --- a/scripts/verify-story-asset-data.mjs +++ b/scripts/verify-story-asset-data.mjs @@ -7,6 +7,8 @@ const validMarkerSides = new Set(['ally', 'enemy', 'objective']); const validRewardTones = new Set(['reward', 'next', 'bond']); const validDirections = new Set(['south', 'east', 'north', 'west']); const portraitlessActorIds = new Set(['rebel-leader']); +const maxAdjacentBackgroundDuplicateRate = 0.02; +const maxAdjacentBackgroundRun = 3; const server = await createServer({ logLevel: 'error', @@ -17,8 +19,12 @@ const server = await createServer({ try { const scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts'); const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); - const { storyBackgroundAssets, storyBackgroundKeyForPage, storyBackgroundKeysFor } = - await server.ssrLoadModule('/src/game/data/storyAssets.ts'); + const { + storyBackgroundAssets, + storyBackgroundKeysFor, + storyBackgroundKeysForPages, + storyBackgroundPageHasFixedAssignment + } = await server.ssrLoadModule('/src/game/data/storyAssets.ts'); const { portraitAssetEntriesForKey, portraitKeyForSpeaker } = await server.ssrLoadModule('/src/game/data/portraitAssets.ts'); const { storyCutsceneActorProfileFor } = await server.ssrLoadModule('/src/game/data/storyCutscenes.ts'); const { hasUnitSheetAsset } = await server.ssrLoadModule('/src/game/data/unitAssets.ts'); @@ -31,6 +37,12 @@ try { const selectedBackgroundUrlsByBase = new Map(); const usedPortraitKeys = new Set(); const usedCutsceneActorIds = new Set(); + const adjacencyMetrics = { + pairs: 0, + duplicatePairs: 0, + avoidableDuplicatePairs: 0, + maxRun: 0 + }; let pageCount = 0; let cutsceneCount = 0; @@ -40,6 +52,23 @@ try { return; } + const selectedKeys = storyBackgroundKeysForPages(pages); + const repeatedSelection = storyBackgroundKeysForPages(pages); + if (selectedKeys.some((key, index) => key !== repeatedSelection[index])) { + errors.push(`${source}: story background sequence selection must be deterministic`); + } + + validateStoryBackgroundSequence({ + errors, + source, + pages, + selectedKeys, + storyBackgroundAssets, + storyBackgroundKeysFor, + storyBackgroundPageHasFixedAssignment, + metrics: adjacencyMetrics + }); + pages.forEach((page, index) => { pageCount += 1; uniquePageIds.add(page.id); @@ -48,13 +77,13 @@ try { source, index, page, + selectedKey: selectedKeys[index], usedBackgroundKeys, selectedBackgroundKeys, selectedBackgroundUrlsByBase, usedPortraitKeys, usedCutsceneActorIds, storyBackgroundAssets, - storyBackgroundKeyForPage, storyBackgroundKeysFor, portraitAssetEntriesForKey, portraitKeyForSpeaker, @@ -69,6 +98,7 @@ try { validateStoryBackgroundVariantCoverage(errors, selectedBackgroundKeys); validateRepeatedStoryBackgroundDistribution(errors, selectedBackgroundUrlsByBase); + validateStoryBackgroundAdjacencyMetrics(errors, adjacencyMetrics); if (errors.length) { console.error(`Story asset data verification failed with ${errors.length} issue(s):`); @@ -78,11 +108,16 @@ try { } process.exitCode = 1; } else { + const adjacentDuplicateRate = adjacencyMetrics.pairs > 0 + ? adjacencyMetrics.duplicatePairs / adjacencyMetrics.pairs + : 0; console.log( `Verified ${pageCount} story page references (${uniquePageIds.size} unique pages), ` + `${cutsceneCount} cutscenes, ${usedBackgroundKeys.size} background candidates, ` + `${selectedBackgroundKeys.size} selected backgrounds, ` + - `${usedPortraitKeys.size} portrait keys, and ${usedCutsceneActorIds.size} cutscene actor profiles.` + `${usedPortraitKeys.size} portrait keys, and ${usedCutsceneActorIds.size} cutscene actor profiles; ` + + `${adjacencyMetrics.duplicatePairs}/${adjacencyMetrics.pairs} adjacent background repeats ` + + `(${(adjacentDuplicateRate * 100).toFixed(1)}%), max run ${adjacencyMetrics.maxRun}.` ); } } finally { @@ -146,11 +181,11 @@ function validateBackground(context, pageContext) { const { errors, page, + selectedKey, usedBackgroundKeys, selectedBackgroundKeys, selectedBackgroundUrlsByBase, storyBackgroundAssets, - storyBackgroundKeyForPage, storyBackgroundKeysFor } = context; if (!page.background) { @@ -164,11 +199,6 @@ function validateBackground(context, pageContext) { } keys.forEach((key) => usedBackgroundKeys.add(key)); - const selectedKey = storyBackgroundKeyForPage(page.background, page.id); - const repeatedSelection = storyBackgroundKeyForPage(page.background, page.id); - if (selectedKey !== repeatedSelection) { - errors.push(`${pageContext}: story background selection must be deterministic`); - } if (!keys.includes(selectedKey)) { errors.push(`${pageContext}: selected background "${selectedKey}" is not a candidate for "${page.background}"`); return; @@ -192,6 +222,98 @@ function validateBackground(context, pageContext) { selectedBackgroundUrlsByBase.set(page.background, backgroundGroup); } +function validateStoryBackgroundSequence(context) { + const { + errors, + source, + pages, + selectedKeys, + storyBackgroundAssets, + storyBackgroundKeysFor, + storyBackgroundPageHasFixedAssignment, + metrics + } = context; + let previousUrl; + let previousPage; + let previousKey; + let currentRun = 0; + + pages.forEach((page, index) => { + const selectedKey = selectedKeys[index]; + const selectedUrl = storyBackgroundAssets[selectedKey] ?? selectedKey; + if (index > 0) { + metrics.pairs += 1; + } + + if (previousUrl !== undefined && selectedUrl === previousUrl) { + metrics.duplicatePairs += 1; + currentRun += 1; + + const previousUrls = selectableStoryBackgroundUrls( + previousPage, + previousKey, + storyBackgroundAssets, + storyBackgroundKeysFor, + storyBackgroundPageHasFixedAssignment + ); + const currentUrls = selectableStoryBackgroundUrls( + page, + selectedKey, + storyBackgroundAssets, + storyBackgroundKeysFor, + storyBackgroundPageHasFixedAssignment + ); + const hasDistinctPair = [...previousUrls].some((leftUrl) => + [...currentUrls].some((rightUrl) => leftUrl !== rightUrl) + ); + if (hasDistinctPair) { + metrics.avoidableDuplicatePairs += 1; + errors.push(`${source}[${index}]/${page.id}: avoidable adjacent story background repeat`); + } + } else { + currentRun = 1; + } + + metrics.maxRun = Math.max(metrics.maxRun, currentRun); + previousUrl = selectedUrl; + previousPage = page; + previousKey = selectedKey; + }); +} + +function selectableStoryBackgroundUrls( + page, + selectedKey, + storyBackgroundAssets, + storyBackgroundKeysFor, + storyBackgroundPageHasFixedAssignment +) { + const selectedUrl = storyBackgroundAssets[selectedKey] ?? selectedKey; + if (!page || storyBackgroundPageHasFixedAssignment(page.id)) { + return new Set([selectedUrl]); + } + return new Set( + storyBackgroundKeysFor(page.background) + .map((key) => storyBackgroundAssets[key] ?? key) + ); +} + +function validateStoryBackgroundAdjacencyMetrics(errors, metrics) { + const duplicateRate = metrics.pairs > 0 ? metrics.duplicatePairs / metrics.pairs : 0; + if (metrics.avoidableDuplicatePairs > 0) { + errors.push(`${metrics.avoidableDuplicatePairs} avoidable adjacent story background repeat(s) remain`); + } + if (duplicateRate > maxAdjacentBackgroundDuplicateRate) { + errors.push( + `adjacent story background duplicate rate ${(duplicateRate * 100).toFixed(1)}% exceeds ` + + `${(maxAdjacentBackgroundDuplicateRate * 100).toFixed(1)}%` + ); + } + if (metrics.maxRun > maxAdjacentBackgroundRun) { + errors.push(`story background max run ${metrics.maxRun} exceeds ${maxAdjacentBackgroundRun}`); + } +} + function validateStoryBackgroundVariantCoverage(errors, selectedBackgroundKeys) { const firstVariantNumber = 33; const lastVariantNumber = 134; diff --git a/src/game/data/sortieRosterHistory.ts b/src/game/data/sortieRosterHistory.ts new file mode 100644 index 0000000..e8cdae4 --- /dev/null +++ b/src/game/data/sortieRosterHistory.ts @@ -0,0 +1,257 @@ +export type SortieHistoryRecruitLike = string | { + unitId?: string; +}; + +export type SortieHistoryBattleLike = { + battleId?: string; + completedAt?: string; + sortiePerformance?: { + selectedUnitIds?: readonly string[]; + }; + campaignRewards?: { + recruits?: readonly SortieHistoryRecruitLike[]; + }; +}; + +export type SortieBattleHistoryLike = + | Readonly> + | readonly SortieHistoryBattleLike[]; + +export type SortieRosterHistoryBadgeKind = 'recent-recruit' | 'first-sortie' | 'long-wait' | 'active'; + +export type SortieRosterHistoryBadge = { + kind: SortieRosterHistoryBadgeKind; + label: string; +}; + +export type SortieRosterUnitHistory = { + unitId: string; + sortieCount: number; + firstSortiePending: boolean; + battlesSinceLastSortie: number | null; + recentRecruit: boolean; + battlesSinceRecruit: number | null; + recruitBattleId?: string; + lastSortieBattleId?: string; + badge: SortieRosterHistoryBadge; + summary: string; +}; + +export type SortieRosterHistoryOptions = { + recentRecruitBattleWindow?: number; + longWaitBattleThreshold?: number; +}; + +export const defaultRecentRecruitBattleWindow = 1; +export const defaultLongWaitBattleThreshold = 3; + +type NormalizedHistoryBattle = { + battleId?: string; + completedAtMs?: number; + selectedUnitIds: string[]; + recruitUnitIds: string[]; + sourceIndex: number; +}; + +type UnitHistoryAccumulator = { + sortieCount: number; + lastSortieIndex?: number; + recruitBattleIndex?: number; +}; + +export function deriveSortieRosterHistory( + unitIds: readonly string[], + battleHistory: SortieBattleHistoryLike, + options: SortieRosterHistoryOptions = {} +): SortieRosterUnitHistory[] { + const normalizedUnitIds = uniqueIds(unitIds); + const battles = normalizeBattleHistory(battleHistory); + const recentRecruitBattleWindow = nonNegativeInteger( + options.recentRecruitBattleWindow, + defaultRecentRecruitBattleWindow + ); + const longWaitBattleThreshold = positiveInteger( + options.longWaitBattleThreshold, + defaultLongWaitBattleThreshold + ); + const accumulatorByUnitId = new Map( + normalizedUnitIds.map((unitId) => [unitId, { sortieCount: 0 }]) + ); + + battles.forEach((battle, battleIndex) => { + battle.selectedUnitIds.forEach((unitId) => { + const accumulator = accumulatorByUnitId.get(unitId); + if (!accumulator) { + return; + } + accumulator.sortieCount += 1; + accumulator.lastSortieIndex = battleIndex; + }); + battle.recruitUnitIds.forEach((unitId) => { + const accumulator = accumulatorByUnitId.get(unitId); + if (accumulator && accumulator.recruitBattleIndex === undefined) { + accumulator.recruitBattleIndex = battleIndex; + } + }); + }); + + return normalizedUnitIds.map((unitId) => { + const accumulator = accumulatorByUnitId.get(unitId) ?? { sortieCount: 0 }; + const firstSortiePending = accumulator.sortieCount === 0; + const battlesSinceLastSortie = accumulator.lastSortieIndex === undefined + ? null + : battles.length - accumulator.lastSortieIndex - 1; + const battlesSinceRecruit = accumulator.recruitBattleIndex === undefined + ? null + : battles.length - accumulator.recruitBattleIndex - 1; + const recentRecruit = battlesSinceRecruit !== null && battlesSinceRecruit <= recentRecruitBattleWindow; + const badge = createHistoryBadge({ + sortieCount: accumulator.sortieCount, + firstSortiePending, + battlesSinceLastSortie, + recentRecruit, + longWaitBattleThreshold + }); + + return { + unitId, + sortieCount: accumulator.sortieCount, + firstSortiePending, + battlesSinceLastSortie, + recentRecruit, + battlesSinceRecruit, + ...(accumulator.recruitBattleIndex === undefined + ? {} + : { recruitBattleId: battles[accumulator.recruitBattleIndex]?.battleId }), + ...(accumulator.lastSortieIndex === undefined + ? {} + : { lastSortieBattleId: battles[accumulator.lastSortieIndex]?.battleId }), + badge, + summary: createHistorySummary({ + sortieCount: accumulator.sortieCount, + firstSortiePending, + battlesSinceLastSortie, + recentRecruit, + battlesSinceRecruit + }) + }; + }); +} + +export function deriveSortieRosterHistoryByUnitId( + unitIds: readonly string[], + battleHistory: SortieBattleHistoryLike, + options: SortieRosterHistoryOptions = {} +) { + return new Map( + deriveSortieRosterHistory(unitIds, battleHistory, options) + .map((history) => [history.unitId, history] as const) + ); +} + +function normalizeBattleHistory(battleHistory: SortieBattleHistoryLike): NormalizedHistoryBattle[] { + const entries = Array.isArray(battleHistory) + ? battleHistory.map((battle, sourceIndex) => ({ battle, sourceIndex })) + : Object.entries(battleHistory).map(([battleId, battle], sourceIndex) => ({ + battle: { ...battle, battleId: normalizedId(battle.battleId) ?? normalizedId(battleId) }, + sourceIndex + })); + const normalized = entries.map(({ battle, sourceIndex }) => ({ + battleId: normalizedId(battle.battleId), + completedAtMs: validTimestamp(battle.completedAt), + selectedUnitIds: uniqueIds(battle.sortiePerformance?.selectedUnitIds ?? []), + recruitUnitIds: uniqueIds((battle.campaignRewards?.recruits ?? []).map(recruitUnitId)), + sourceIndex + })); + + if (normalized.length > 1 && normalized.every((battle) => battle.completedAtMs !== undefined)) { + normalized.sort((left, right) => + (left.completedAtMs ?? 0) - (right.completedAtMs ?? 0) || left.sourceIndex - right.sourceIndex + ); + } + return normalized; +} + +function createHistoryBadge(context: { + sortieCount: number; + firstSortiePending: boolean; + battlesSinceLastSortie: number | null; + recentRecruit: boolean; + longWaitBattleThreshold: number; +}): SortieRosterHistoryBadge { + if (context.recentRecruit) { + return { kind: 'recent-recruit', label: 'NEW' }; + } + if (context.firstSortiePending) { + return { kind: 'first-sortie', label: '미출전' }; + } + if ( + context.battlesSinceLastSortie !== null && + context.battlesSinceLastSortie >= context.longWaitBattleThreshold + ) { + return { kind: 'long-wait', label: `대기 ${context.battlesSinceLastSortie}전` }; + } + return { kind: 'active', label: `출전 ${context.sortieCount}회` }; +} + +function createHistorySummary(context: { + sortieCount: number; + firstSortiePending: boolean; + battlesSinceLastSortie: number | null; + recentRecruit: boolean; + battlesSinceRecruit: number | null; +}) { + if (context.recentRecruit && context.firstSortiePending) { + return '최근 합류 · 첫 출전 대기'; + } + if (context.firstSortiePending) { + return context.battlesSinceRecruit === null + ? '출전 기록 없음 · 첫 출전 대기' + : `합류 후 ${context.battlesSinceRecruit}전 경과 · 첫 출전 대기`; + } + + const lastSortie = context.battlesSinceLastSortie === 0 + ? '직전 전투 출전' + : `${context.battlesSinceLastSortie}전 전 마지막 출전`; + return `${context.recentRecruit ? '최근 합류 · ' : ''}출전 ${context.sortieCount}회 · ${lastSortie}`; +} + +function recruitUnitId(recruit: SortieHistoryRecruitLike) { + return typeof recruit === 'string' ? recruit : recruit?.unitId; +} + +function uniqueIds(values: readonly unknown[]) { + const seen = new Set(); + return values.flatMap((value) => { + const id = normalizedId(value); + if (!id || seen.has(id)) { + return []; + } + seen.add(id); + return [id]; + }); +} + +function normalizedId(value: unknown) { + if (typeof value !== 'string') { + return undefined; + } + const normalized = value.trim(); + return normalized || undefined; +} + +function validTimestamp(value: unknown) { + if (typeof value !== 'string') { + return undefined; + } + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : undefined; +} + +function nonNegativeInteger(value: number | undefined, fallback: number) { + return Number.isFinite(value) ? Math.max(0, Math.floor(value ?? fallback)) : fallback; +} + +function positiveInteger(value: number | undefined, fallback: number) { + return Number.isFinite(value) ? Math.max(1, Math.floor(value ?? fallback)) : fallback; +} diff --git a/src/game/data/storyAssets.ts b/src/game/data/storyAssets.ts index cbe59c8..ab10a62 100644 --- a/src/game/data/storyAssets.ts +++ b/src/game/data/storyAssets.ts @@ -100,6 +100,90 @@ export function storyBackgroundKeyForPage(baseKey: string, pageId: string) { return keys[stableStoryHash(pageId) % keys.length]; } +type StoryBackgroundPageReference = Readonly<{ + id: string; + background: string; +}>; + +/** + * Select backgrounds in page order while preserving stable per-page defaults. + * The planner minimizes physical-image repeats across the whole sequence + * first, then minimizes deviations from each page's hash-selected preference. + * Explicit assignments remain authoritative. + */ +export function storyBackgroundKeysForPages(pages: readonly StoryBackgroundPageReference[]) { + let plans: StoryBackgroundSequencePlan[] = []; + + pages.forEach((page, pageIndex) => { + const options = storyBackgroundOptionsForPage(page); + plans = options.map((key, preferenceRank) => { + if (pageIndex === 0) { + return { keys: [key], repeats: 0, preferenceCost: preferenceRank }; + } + + let bestPlan: StoryBackgroundSequencePlan | undefined; + plans.forEach((previousPlan) => { + const previousKey = previousPlan.keys[previousPlan.keys.length - 1]; + const repeats = previousPlan.repeats + (storyBackgroundUrl(previousKey) === storyBackgroundUrl(key) ? 1 : 0); + const candidatePlan = { + keys: [...previousPlan.keys, key], + repeats, + preferenceCost: previousPlan.preferenceCost + preferenceRank + }; + if (!bestPlan || storyBackgroundPlanIsBetter(candidatePlan, bestPlan)) { + bestPlan = candidatePlan; + } + }); + return bestPlan ?? { keys: [key], repeats: 0, preferenceCost: preferenceRank }; + }); + }); + + return plans.reduce( + (bestPlan, candidatePlan) => + !bestPlan || storyBackgroundPlanIsBetter(candidatePlan, bestPlan) ? candidatePlan : bestPlan, + undefined + )?.keys ?? []; +} + +export function storyBackgroundPageHasFixedAssignment(pageId: string) { + return Object.prototype.hasOwnProperty.call(storyBackgroundPageAssignments, pageId); +} + +type StoryBackgroundSequencePlan = { + keys: string[]; + repeats: number; + preferenceCost: number; +}; + +function storyBackgroundOptionsForPage(page: StoryBackgroundPageReference) { + const keys = storyBackgroundKeysFor(page.background); + if (keys.length === 0) { + return [page.background]; + } + + const fixedKey = storyBackgroundPageAssignments[page.id]; + if (fixedKey && keys.includes(fixedKey)) { + return [fixedKey]; + } + + const preferredKey = storyBackgroundKeyForPage(page.background, page.id); + const preferredIndex = Math.max(0, keys.indexOf(preferredKey)); + return [ + preferredKey, + ...keys.slice(preferredIndex + 1), + ...keys.slice(0, preferredIndex) + ].filter((key, index, options) => options.indexOf(key) === index); +} + +function storyBackgroundPlanIsBetter(candidate: StoryBackgroundSequencePlan, current: StoryBackgroundSequencePlan) { + return candidate.repeats < current.repeats || + (candidate.repeats === current.repeats && candidate.preferenceCost < current.preferenceCost); +} + +function storyBackgroundUrl(key: string) { + return storyBackgroundAssets[key] ?? key; +} + function stableStoryHash(value: string) { let hash = 0; for (let index = 0; index < value.length; index += 1) { diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 11cf518..ce0af4e 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -51,6 +51,10 @@ import { type SortieSynergyComparison, type SortieSynergySnapshot } from '../data/sortieSynergy'; +import { + deriveSortieRosterHistoryByUnitId, + type SortieRosterUnitHistory +} from '../data/sortieRosterHistory'; import { evaluateSortiePerformanceReview, sortiePerformanceGradeForScore, @@ -255,11 +259,22 @@ type VictoryRewardCardId = 'gold' | 'equipment' | 'supplies' | 'reputation' | 'r type VictoryRewardActionId = 'equipment' | 'supplies' | 'sortie' | 'close'; +type VictoryRewardIcon = + | { kind: 'mark'; mark: string } + | { kind: 'battle-ui'; iconKey: BattleUiIconKey } + | { kind: 'item'; itemId: string } + | { kind: 'portrait'; unitId: string }; + type VictoryRewardCardView = { id: VictoryRewardCardId; label: string; items: string[]; background: Phaser.GameObjects.Rectangle; + iconKind: VictoryRewardIcon['kind']; + iconKey: string; + iconReady: boolean; + isNew: boolean; + actionId?: VictoryRewardActionId; }; type VictoryRewardActionView = { @@ -386,6 +401,7 @@ type SortieFocusedUnitSummary = SortieUnitTacticalSummary & { reserveTrainingBondPreviewLine: string; reserveTrainingBondPartnerCount: number; reserveTrainingPlanLine: string; + serviceHistory: SortieRosterUnitHistory; terrainScore: number; terrainGrade: string; equipment: { @@ -13045,7 +13061,21 @@ export class CampScene extends Phaser.Scene { ); subtitle.setDepth(depth + 2); - const cardDefinitions: Array<{ id: VictoryRewardCardId; label: string; items: string[] }> = [ + const equipmentReward = rewards?.equipment[0]; + const equipmentItem = equipmentReward ? this.itemForRewardLabel(equipmentReward) : undefined; + const supplyReward = supplyItems[0]; + const supply = supplyReward ? this.supplyForRewardLabel(supplyReward) : undefined; + const recruit = rewards?.recruits[0]; + const cardDefinitions: Array<{ + id: VictoryRewardCardId; + label: string; + items: string[]; + accent: number; + icon: VictoryRewardIcon; + actionId?: VictoryRewardActionId; + actionHint: string; + isNew?: boolean; + }> = [ { id: 'gold', label: '군자금', @@ -13054,13 +13084,58 @@ export class CampScene extends Phaser.Scene { ...(presentation.sortieOrderBonus ? [`${presentation.sortieOrderBonus.label} 첫 공훈 +${presentation.sortieOrderBonus.rewardGold} 포함`] : []) - ] + ], + accent: palette.gold, + icon: { kind: 'mark', mark: '金' }, + actionHint: '승전 자산에 반영 완료' }, - { id: 'equipment', label: '장비', items: [...(rewards?.equipment ?? [])] }, - { id: 'supplies', label: '보급', items: supplyItems }, - { id: 'reputation', label: '명성·기록', items: [...(rewards?.reputation ?? [])] }, - { id: 'recruits', label: '합류 무장', items: rewards?.recruits.map((recruit) => recruit.name) ?? [] }, - { id: 'unlocks', label: '새 전장', items: rewards?.unlocks.map((unlock) => unlock.title) ?? [] } + { + id: 'equipment', + label: '장비', + items: [...(rewards?.equipment ?? [])], + accent: equipmentItem?.rank === 'treasure' ? palette.gold : palette.blue, + icon: equipmentItem ? { kind: 'item', itemId: equipmentItem.id } : { kind: 'battle-ui', iconKey: 'sword' }, + actionId: 'equipment', + actionHint: '눌러서 장비 비교', + isNew: Boolean(equipmentReward) + }, + { + id: 'supplies', + label: '보급', + items: supplyItems, + accent: palette.green, + icon: { kind: 'battle-ui', iconKey: supply?.usableId ?? 'bean' }, + actionId: 'supplies', + actionHint: '눌러서 보급 확인' + }, + { + id: 'reputation', + label: '명성·기록', + items: [...(rewards?.reputation ?? [])], + accent: 0xc887dc, + icon: { kind: 'mark', mark: '名' }, + actionHint: '전황 기록에 반영 완료' + }, + { + id: 'recruits', + label: '합류 무장', + items: rewards?.recruits.map((rewardRecruit) => rewardRecruit.name) ?? [], + accent: palette.gold, + icon: recruit ? { kind: 'portrait', unitId: recruit.unitId } : { kind: 'battle-ui', iconKey: 'leadership' }, + actionId: 'sortie', + actionHint: '눌러서 편성에 배치', + isNew: Boolean(recruit) + }, + { + id: 'unlocks', + label: '새 전장', + items: rewards?.unlocks.map((unlock) => unlock.title) ?? [], + accent: 0xe78a55, + icon: { kind: 'battle-ui', iconKey: 'terrain' }, + actionId: 'sortie', + actionHint: '눌러서 다음 전장 준비', + isNew: Boolean(rewards?.unlocks.length) + } ]; const cardWidth = 252; const cardHeight = 104; @@ -13069,28 +13144,75 @@ export class CampScene extends Phaser.Scene { const row = Math.floor(index / 3); const cardX = panelX + 34 + column * 268; const cardY = panelY + 128 + row * 120; - const background = this.trackVictoryReward( - this.add.rectangle(cardX, cardY, cardWidth, cardHeight, definition.id === 'gold' ? 0x2d2619 : 0x141f2a, 0.98) - ); + const hasReward = definition.items.length > 0; + const actionable = hasReward && Boolean(definition.actionId); + const restingFill = definition.id === 'gold' ? 0x2d2619 : hasReward ? 0x17232e : 0x111820; + const background = this.trackVictoryReward(this.add.rectangle(cardX, cardY, cardWidth, cardHeight, restingFill, 0.98)); background.setOrigin(0); background.setDepth(depth + 2); - background.setStrokeStyle(1, definition.id === 'gold' ? palette.gold : palette.blue, definition.items.length > 0 ? 0.78 : 0.38); + background.setStrokeStyle(hasReward ? 2 : 1, definition.accent, hasReward ? 0.78 : 0.3); + + const iconPlate = this.trackVictoryReward(this.add.rectangle(cardX + 12, cardY + 14, 62, 62, 0x080d13, hasReward ? 0.96 : 0.64)); + iconPlate.setOrigin(0); + iconPlate.setDepth(depth + 3); + iconPlate.setStrokeStyle(1, definition.accent, hasReward ? 0.72 : 0.26); + const iconState = this.renderVictoryRewardIcon(definition.icon, cardX + 43, cardY + 45, 50, depth + 4, hasReward ? 1 : 0.42); const label = this.trackVictoryReward( - this.add.text(cardX + 16, cardY + 13, definition.label, this.textStyle(13, definition.id === 'gold' ? '#ffdf7b' : '#9fc8e8', true)) + this.add.text(cardX + 86, cardY + 11, definition.label, this.textStyle(12, hasReward ? '#9fc8e8' : '#77818c', true)) ); label.setDepth(depth + 3); + if (definition.isNew && hasReward) { + const newBadge = this.trackVictoryReward(this.add.rectangle(cardX + cardWidth - 30, cardY + 16, 44, 19, 0x7b271f, 0.98)); + newBadge.setDepth(depth + 4); + newBadge.setStrokeStyle(1, 0xffb08e, 0.9); + const newLabel = this.trackVictoryReward(this.add.text(cardX + cardWidth - 30, cardY + 16, 'NEW', this.textStyle(9, '#fff0d8', true))); + newLabel.setOrigin(0.5); + newLabel.setDepth(depth + 5); + } + const itemText = definition.items.length > 0 ? definition.items.join(' · ') : '변동 없음'; const value = this.trackVictoryReward( - this.add.text(cardX + 16, cardY + 40, itemText, { - ...this.textStyle(definition.id === 'gold' ? 17 : 12, definition.items.length > 0 ? '#f2e3bf' : '#77818c', true), - wordWrap: { width: cardWidth - 32, useAdvancedWrap: true }, - lineSpacing: 4 + this.add.text(cardX + 86, cardY + 34, itemText, { + ...this.textStyle(definition.id === 'gold' ? 16 : 12, hasReward ? '#f2e3bf' : '#77818c', true), + wordWrap: { width: cardWidth - 100, useAdvancedWrap: true }, + lineSpacing: 3, + maxLines: 2 }) ); value.setDepth(depth + 3); - this.victoryRewardCards.push({ ...definition, background }); + + const actionHint = this.trackVictoryReward( + this.add.text(cardX + 86, cardY + cardHeight - 18, hasReward ? definition.actionHint : '이번 전투 변동 없음', this.textStyle(9, actionable ? '#ffdf7b' : '#7f8994', actionable)) + ); + actionHint.setDepth(depth + 3); + + if (actionable && definition.actionId) { + background.setInteractive({ useHandCursor: true }); + background.on('pointerover', () => { + background.setFillStyle(definition.id === 'gold' ? 0x3a3020 : 0x223342, 1); + background.setStrokeStyle(2, palette.gold, 0.98); + iconPlate.setStrokeStyle(2, palette.gold, 0.92); + }); + background.on('pointerout', () => { + background.setFillStyle(restingFill, 0.98); + background.setStrokeStyle(2, definition.accent, 0.78); + iconPlate.setStrokeStyle(1, definition.accent, 0.72); + }); + background.on('pointerdown', () => this.completeVictoryRewardAcknowledgement(definition.actionId!)); + } + this.victoryRewardCards.push({ + id: definition.id, + label: definition.label, + items: definition.items, + background, + iconKind: definition.icon.kind, + iconKey: iconState.key, + iconReady: iconState.ready, + isNew: Boolean(definition.isNew && hasReward), + ...(actionable && definition.actionId ? { actionId: definition.actionId } : {}) + }); }); const note = rewards?.note?.trim(); @@ -13150,6 +13272,13 @@ export class CampScene extends Phaser.Scene { private completeVictoryRewardAcknowledgement(action: VictoryRewardActionId) { const report = this.pendingVictoryRewardReport; + const rewardedRecruitId = report?.campaignRewards?.recruits[0]?.unitId; + if (action === 'sortie' && rewardedRecruitId && this.sortieAllies().some((unit) => unit.id === rewardedRecruitId)) { + this.focusVictoryRewardRecruit(rewardedRecruitId); + } + if (action === 'equipment' && report) { + this.focusVictoryRewardEquipment(report); + } if (report) { this.campaign = acknowledgeCampaignVictoryReward(report.battleId); if (this.campaign.acknowledgedVictoryRewardBattleIds.includes(report.battleId)) { @@ -13191,6 +13320,108 @@ export class CampScene extends Phaser.Scene { this.victoryRewardActions = []; } + private rewardInventoryLabel(label: string) { + return label.replace(/\s*(?:[+xX×-]?\s*\d+)\s*$/u, '').trim(); + } + + private itemForRewardLabel(label: string) { + return findItemByName(this.rewardInventoryLabel(label)); + } + + private supplyForRewardLabel(label: string) { + return campSupplyByLabel.get(this.rewardInventoryLabel(label)); + } + + private focusVictoryRewardEquipment(report: FirstBattleReport) { + const rewardItemIds = new Set( + (report.campaignRewards?.equipment ?? []) + .map((label) => this.itemForRewardLabel(label)?.id) + .filter((itemId): itemId is string => Boolean(itemId)) + ); + if (rewardItemIds.size === 0) { + return; + } + const rewardIndex = this.equipmentInventoryEntries().findIndex((entry) => rewardItemIds.has(entry.item.id)); + if (rewardIndex >= 0) { + this.equipmentInventoryPage = Math.floor(rewardIndex / equipmentInventoryPageSize); + } + } + + private focusVictoryRewardRecruit(unitId: string) { + this.sortieFocusedUnitId = unitId; + const displayUnits = this.sortieRosterDisplayUnits(this.sortieRosterUnits()); + const recruitIndex = displayUnits.findIndex((unit) => unit.id === unitId); + if (recruitIndex >= 0) { + this.sortiePortraitRosterPage = Math.floor(recruitIndex / sortiePortraitRosterPageSize); + } + } + + private renderVictoryRewardIcon( + icon: VictoryRewardIcon, + x: number, + y: number, + size: number, + depth: number, + alpha: number + ): { key: string; ready: boolean } { + if (icon.kind === 'item') { + const textureKey = `item-${icon.itemId}`; + if (this.textures.exists(textureKey)) { + const image = this.trackVictoryReward(this.add.image(x, y, textureKey)); + image.setDisplaySize(this.campUiLength(size), this.campUiLength(size)); + image.setAlpha(alpha); + image.setDepth(depth); + return { key: textureKey, ready: true }; + } + return this.renderVictoryRewardIcon({ kind: 'battle-ui', iconKey: 'sword' }, x, y, size, depth, alpha); + } + + if (icon.kind === 'portrait') { + const unit = this.currentUnits().find((candidate) => candidate.id === icon.unitId); + const portraitKey = campaignPortraitKeysByUnitId[icon.unitId]; + const textureKey = portraitKey + ? [portraitCardTextureKey(portraitKey), portraitTextureKey(portraitKey)].find( + (candidate): candidate is string => Boolean(candidate && this.textures.exists(candidate)) + ) + : undefined; + if (textureKey) { + const image = this.trackVictoryReward(this.add.image(x, y, textureKey)); + image.setDisplaySize(this.campUiLength(size), this.campUiLength(size)); + image.setAlpha(alpha); + image.setDepth(depth); + return { key: textureKey, ready: true }; + } + return this.renderVictoryRewardMark(unit?.name.slice(0, 1) ?? '將', x, y, size, depth, alpha, `portrait-${icon.unitId}`); + } + + if (icon.kind === 'battle-ui') { + const displaySize = this.campUiLength(size); + const textureKey = battleUiIconTextureForSize(displaySize); + if (this.textures.exists(textureKey)) { + const image = this.trackVictoryReward(this.add.image(x, y, textureKey, battleUiIconFrames[icon.iconKey])); + image.setDisplaySize(displaySize, displaySize); + image.setAlpha(alpha); + image.setDepth(depth); + return { key: icon.iconKey, ready: true }; + } + return this.renderVictoryRewardMark('賞', x, y, size, depth, alpha, icon.iconKey); + } + + return this.renderVictoryRewardMark(icon.mark, x, y, size, depth, alpha, icon.mark); + } + + private renderVictoryRewardMark(mark: string, x: number, y: number, size: number, depth: number, alpha: number, key: string) { + const seal = this.trackVictoryReward(this.add.circle(x, y, size / 2, 0x2d2619, 0.94)); + seal.setStrokeStyle(2, palette.gold, 0.74); + seal.setAlpha(alpha); + seal.setDepth(depth); + const label = this.trackVictoryReward(this.add.text(x, y, mark, this.textStyle(Math.max(20, Math.round(size * 0.52)), '#ffdf7b', true))); + label.setOrigin(0.5); + label.setAlpha(alpha); + label.setDepth(depth + 1); + return { key, ready: true }; + } + private handleEscapeKey() { if (this.victoryRewardObjects.length > 0) { this.completeVictoryRewardAcknowledgement('close'); @@ -13777,6 +14008,7 @@ export class CampScene extends Phaser.Scene { const selectedCount = this.selectedSortieUnitIds.length; const maxUnits = this.sortieMaxUnits(); const rangeEnd = pageStart + visibleUnits.length; + const serviceHistoryByUnitId = this.sortieRosterHistoryByUnitId(); const previousEnabled = this.sortiePortraitRosterPage > 0; const nextEnabled = this.sortiePortraitRosterPage < pageCount - 1; const previousButtonBounds = { x: x + width - 100, y: y + 9, width: 40, height: 30 }; @@ -13852,6 +14084,7 @@ export class CampScene extends Phaser.Scene { const availability = this.sortieUnitAvailability(unit); const role = this.sortieFormationRole(unit); const preview = this.sortieUnitSynergyPreview(unit); + const serviceHistory = serviceHistoryByUnitId.get(unit.id); const canPinSwap = availability.available && !selected && selectedCount >= maxUnits && this.isSortieSelected(this.sortieFocusedUnitId) && !this.isRequiredSortieUnit(this.sortieFocusedUnitId); const fill = !availability.available @@ -13937,10 +14170,16 @@ export class CampScene extends Phaser.Scene { portraitView.frame.setStrokeStyle(2, accentColor, focused ? 0.92 : selected ? 0.78 : recommendation ? 0.62 : 0.42); portraitView.image?.setDisplaySize(this.campUiLength(70), this.campUiLength(70)); }); - const statusLabel = !availability.available + const rosterStatusLabel = !availability.available ? required ? `필수 · ${availability.label}` : availability.label : required ? '필수' : selected ? '출전' : pinnedSwapCandidate ? '교체안' : recommendation ? '추천' : '대기'; - const statusColor = !availability.available + const serviceStatusLabel = availability.available && serviceHistory?.badge.kind !== 'active' + ? serviceHistory?.badge.label + : undefined; + const statusLabel = serviceStatusLabel ? `${serviceStatusLabel} · ${rosterStatusLabel}` : rosterStatusLabel; + const statusColor = serviceStatusLabel && serviceHistory + ? this.sortieRosterHistoryTone(serviceHistory).text + : !availability.available ? '#ff9d7d' : required || recommendation || pinnedSwapCandidate ? '#ffdf7b' : selected ? '#a8ffd0' : '#9fb0bf'; const status = this.trackSortie(this.add.text(cardX + cardWidth - 10, cardY + 9, statusLabel, this.textStyle(10, statusColor, true))); @@ -15066,6 +15305,7 @@ export class CampScene extends Phaser.Scene { this.sortieRosterToggleBounds = []; const units = this.sortieRosterDisplayUnits(this.sortieRosterUnits()); + const serviceHistoryByUnitId = this.sortieRosterHistoryByUnitId(); const compactGrid = units.length > 3; const columns = compactGrid ? 2 : 3; const rows = Math.max(1, Math.ceil(units.length / columns)); @@ -15084,6 +15324,7 @@ export class CampScene extends Phaser.Scene { const required = this.isRequiredSortieUnit(unit.id); const recommendation = this.sortieRecommendation(unit.id); const role = this.sortieFormationRole(unit); + const serviceHistory = serviceHistoryByUnitId.get(unit.id); const card = this.trackSortie(this.add.rectangle(cardX, cardY, cardWidth, cardHeight, selected ? 0x172a22 : 0x121820, selected ? 0.98 : 0.78)); card.setOrigin(0); card.setDepth(depth + 1); @@ -15102,6 +15343,9 @@ export class CampScene extends Phaser.Scene { : `${this.sortieFormationRoleLabel(role)} 역할로 전장에 합류합니다.`; if (compactGrid) { this.renderFirstSortiePortrait(cardX + 50, cardY + 55, 72, unit, depth + 2, selected ? 1 : 0.5); + if (serviceHistory) { + this.renderSortieRosterHistoryBadge(serviceHistory, cardX + 50, cardY + 20, 68, depth + 5); + } this.trackSortie(this.add.text(cardX + 96, cardY + 10, `${unit.name} Lv${unit.level}`, this.textStyle(16, selected ? '#f2e3bf' : '#87919c', true))).setDepth(depth + 2); this.trackSortie(this.add.text(cardX + 96, cardY + 36, `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(10, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2); this.drawSortieBar(cardX + 96, cardY + 56, cardWidth - 110, 6, unit.hp / unit.maxHp, selected ? palette.green : 0x53606c, depth + 2); @@ -15110,6 +15354,9 @@ export class CampScene extends Phaser.Scene { this.trackSortie(this.add.text(cardX + 14, cardY + 113, this.compactText(this.sortieStrategyLine(unit.id), 34), this.textStyle(9, selected ? '#9fc8e8' : '#77818c', true))).setDepth(depth + 2); } else { this.renderFirstSortiePortrait(cardX + cardWidth / 2, cardY + 88, 132, unit, depth + 2, selected ? 1 : 0.5); + if (serviceHistory) { + this.renderSortieRosterHistoryBadge(serviceHistory, cardX + cardWidth / 2, cardY + 30, 78, depth + 5); + } this.trackSortie(this.add.text(cardX + 14, cardY + 162, `${unit.name} Lv${unit.level}`, this.textStyle(20, selected ? '#f2e3bf' : '#87919c', true))).setDepth(depth + 2); this.trackSortie(this.add.text(cardX + 14, cardY + 190, `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(12, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2); this.drawSortieBar(cardX + 14, cardY + 211, cardWidth - 28, 7, unit.hp / unit.maxHp, selected ? palette.green : 0x53606c, depth + 2); @@ -16243,6 +16490,7 @@ export class CampScene extends Phaser.Scene { const statusColor = status.tone === 'disabled' ? '#77818c' : status.tone === 'selected' ? '#a8ffd0' : status.tone === 'recommended' ? '#ffdf7b' : '#9fb0bf'; const selected = this.isSortieSelected(unit.id); const synergyPreview = this.sortieUnitSynergyPreview(unit); + const serviceHistory = this.sortieRosterHistoryByUnitId().get(unit.id); const synergyColor = synergyPreview.mode === 'remove' ? '#ff9d7d' : synergyPreview.mode === 'add' || synergyPreview.mode === 'current' @@ -16255,6 +16503,12 @@ export class CampScene extends Phaser.Scene { this.trackSortie(this.add.text(x + width - 16, y + 16, status.label, this.textStyle(11, statusColor, true))).setOrigin(1, 0).setDepth(depth + 1); this.renderSortieBattleUiIcon(this.unitClassIconKey(unit), x + 34, y + 58, 42, depth + 1, availability.available ? 1 : 0.5); this.trackSortie(this.add.text(x + 66, y + 42, `${unit.name} Lv${unit.level}`, this.textStyle(18, availability.available ? '#f2e3bf' : '#87919c', true))).setDepth(depth + 1); + if (serviceHistory) { + const serviceTone = this.sortieRosterHistoryTone(serviceHistory); + this.trackSortie( + this.add.text(x + width - 16, y + 43, this.compactText(serviceHistory.summary, 25), this.textStyle(10, serviceTone.text, true)) + ).setOrigin(1, 0).setDepth(depth + 1); + } this.trackSortie(this.add.text(x + 66, y + 68, `${unit.className} · ${getUnitClass(unit.classKey).role}`, this.textStyle(12, availability.available ? '#d4dce6' : '#77818c', true))).setDepth(depth + 1); this.trackSortie(this.add.text(x + width - 16, y + 68, `HP ${unit.hp}/${unit.maxHp}`, this.textStyle(12, unit.hp < unit.maxHp ? '#ffdf7b' : '#a8ffd0', true))).setOrigin(1, 0).setDepth(depth + 1); this.drawSortieBar(x + 66, y + 88, width - 92, 7, unit.hp / unit.maxHp, availability.available ? palette.green : 0x53606c, depth + 1); @@ -18565,6 +18819,7 @@ export class CampScene extends Phaser.Scene { const terrainScore = this.sortieTerrainScore(unit, scenario); const assignedReserveTrainingFocus = this.campaign?.reserveTrainingAssignments[unit.id]; const reserveTrainingFocus = this.reserveTrainingFocusDefinitionForUnit(unit.id); + const serviceHistory = this.sortieRosterHistoryByUnitId().get(unit.id) ?? this.emptySortieRosterHistory(unit.id); return { id: unit.id, name: unit.name, @@ -18581,6 +18836,7 @@ export class CampScene extends Phaser.Scene { reserveTrainingBondPreviewLine: this.reserveTrainingBondPreviewLine(unit), reserveTrainingBondPartnerCount: this.reserveTrainingBondPartnerNames(unit).length, reserveTrainingPlanLine: this.reserveTrainingPlanLine(unit), + serviceHistory, terrainScore, terrainGrade: this.sortieTerrainGrade(terrainScore), statLine: summary.statLine, @@ -19810,6 +20066,55 @@ export class CampScene extends Phaser.Scene { return { label: '대기', tone: 'reserve' as const }; } + private sortieRosterHistoryByUnitId(allies: UnitData[] = this.sortieAllies()) { + return deriveSortieRosterHistoryByUnitId( + allies.map((unit) => unit.id), + this.campaign?.battleHistory ?? {} + ); + } + + private emptySortieRosterHistory(unitId: string): SortieRosterUnitHistory { + return { + unitId, + sortieCount: 0, + firstSortiePending: true, + battlesSinceLastSortie: null, + recentRecruit: false, + battlesSinceRecruit: null, + badge: { kind: 'first-sortie', label: '미출전' }, + summary: '출전 기록 없음 · 첫 출전 대기' + }; + } + + private sortieRosterHistoryTone(history: SortieRosterUnitHistory) { + switch (history.badge.kind) { + case 'recent-recruit': + return { frame: palette.red, text: '#fff0d8' }; + case 'first-sortie': + return { frame: palette.gold, text: '#ffdf7b' }; + case 'long-wait': + return { frame: 0xe78a55, text: '#ffc29d' }; + default: + return { frame: palette.green, text: '#a8ffd0' }; + } + } + + private renderSortieRosterHistoryBadge( + history: SortieRosterUnitHistory, + x: number, + y: number, + width: number, + depth: number + ) { + const tone = this.sortieRosterHistoryTone(history); + const background = this.trackSortie(this.add.rectangle(x, y, width, 18, 0x090e14, 0.96)); + background.setStrokeStyle(1, tone.frame, 0.9); + background.setDepth(depth); + this.trackSortie(this.add.text(x, y, history.badge.label, this.textStyle(8, tone.text, true))) + .setOrigin(0.5) + .setDepth(depth + 1); + } + private renderUnitFallbackPortrait(x: number, y: number, radius: number, unit: UnitData) { const bg = this.track(this.add.circle(x, y, radius, 0x1f3140, 0.96)); bg.setStrokeStyle(2, palette.gold, 0.58); @@ -23430,6 +23735,7 @@ export class CampScene extends Phaser.Scene { const sortieCoreResonanceCandidates = this.sortieCoreResonanceCandidates(); const sortieCoreResonanceSelection = this.campaign?.sortieResonanceSelection; const sortieRecommendationPlanResults = this.sortieRecommendationPlans(); + const sortieRosterHistoryByUnitId = this.sortieRosterHistoryByUnitId(); const sortieRecommendationPlans = this.sortieRecommendationProjections(); const recommendedPresetId = this.recommendedSortiePresetId(); const sortieFormationPresets = this.sortieFormationPresets(); @@ -23484,6 +23790,12 @@ export class CampScene extends Phaser.Scene { id: card.id, label: card.label, items: [...card.items], + iconKind: card.iconKind, + iconKey: card.iconKey, + iconReady: card.iconReady, + new: card.isNew, + actionId: card.actionId ?? null, + interactive: Boolean(card.background.input?.enabled), bounds: this.sortieObjectBoundsDebug(card.background) })), actions: this.victoryRewardActions.map((action) => ({ @@ -24208,6 +24520,10 @@ export class CampScene extends Phaser.Scene { reserveTrainingPlanLine: this.reserveTrainingPlanLine(unit), recommended: Boolean(this.sortieRecommendation(unit.id)), recommendationReason: this.sortieRecommendation(unit.id)?.reason ?? null, + serviceHistory: (() => { + const history = sortieRosterHistoryByUnitId.get(unit.id) ?? this.emptySortieRosterHistory(unit.id); + return { ...history, badge: { ...history.badge } }; + })(), statLine: summary.statLine, equipmentLine: summary.equipmentLine, bondLine: summary.bondLine, diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index 8a5b98c..fa136e4 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -9,7 +9,11 @@ import { type BattleUiIconKey } from '../data/battleUiIcons'; import { portraitAssetEntriesForKey, portraitKeyForSpeaker, type PortraitAssetEntry } from '../data/portraitAssets'; -import { storyBackgroundAssets, storyBackgroundKeyForPage } from '../data/storyAssets'; +import { + storyBackgroundAssets, + storyBackgroundKeyForPage, + storyBackgroundKeysForPages +} from '../data/storyAssets'; import { ensureUnitAnimations, loadUnitBaseSheets, @@ -130,6 +134,7 @@ const firstBattleWarmupUnitTextureKeys = [ export class StoryScene extends Phaser.Scene { private pageIndex = 0; private pages: StoryPage[] = prologuePages; + private selectedStoryBackgroundKeys = storyBackgroundKeysForPages(prologuePages); private nextScene = 'BattleScene'; private nextSceneData?: Record; private transitioning = false; @@ -159,6 +164,7 @@ export class StoryScene extends Phaser.Scene { init(data?: StorySceneData) { this.pages = data?.pages?.length ? data.pages : prologuePages; + this.selectedStoryBackgroundKeys = storyBackgroundKeysForPages(this.pages); this.nextScene = data?.nextScene ?? 'BattleScene'; this.nextSceneData = data?.nextSceneData; this.pageIndex = 0; @@ -745,7 +751,10 @@ export class StoryScene extends Phaser.Scene { } private pageBackgroundAssetKey(page: StoryPage) { - const key = storyBackgroundKeyForPage(page.background, page.id); + const pageIndex = this.pages.indexOf(page); + const key = pageIndex >= 0 + ? this.selectedStoryBackgroundKeys[pageIndex] + : storyBackgroundKeyForPage(page.background, page.id); return storyBackgroundAssets[key] ? key : page.background; }