feat: enrich campaign reward and roster visuals

This commit is contained in:
2026-07-19 12:16:30 +09:00
parent 7c6a8174d1
commit f83296db7b
11 changed files with 1164 additions and 205 deletions

View File

@@ -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`<br>`twentySeventhBattleIntroPages/twenty-seventh-pang-tong-rumor`<br>`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`<br>`fortyEighthBattleIntroPages/forty-eighth-zhuge-amnesty`<br>`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`<br>`tenthBattleIntroPages/tenth-lu-bu-takes-xuzhou`<br>`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`<br>`thirtyFifthBattleIntroPages/thirty-fifth-ma-chao-first-sortie`<br>`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`<br>`twentyThirdBattleIntroPages/twenty-third-zhuge-plan`<br>`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`<br>`nineteenthBattleIntroPages/nineteenth-zhao-yun-rescue`<br>`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`<br>`firstBattleVictoryPages/first-victory-liu-bei`<br>`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`<br>`thirtyNinthBattleIntroPages/thirty-ninth-guan-yu-north`<br>`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`<br>`sixteenthBattleIntroPages/sixteenth-zhao-yun-returns`<br>`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`<br>`thirtyEighthBattleIntroPages/thirty-eighth-guan-yu-front`<br>`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`<br>`seventhBattleIntroPages/seventh-liu-bei-xuzhou`<br>`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`<br>`fiftySeventhBattleIntroPages/fifty-seventh-jieting-order`<br>`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`<br>`twentySixthBattleVictoryPages/twenty-sixth-victory-changsha-open`<br>`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`<br>`twentyFifthBattleIntroPages/twenty-fifth-zhuge-formation`<br>`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`<br>`thirtyFifthBattleVictoryPages/thirty-fifth-victory-pass-opened`<br>`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`<br>`twentyFourthBattleIntroPages/twenty-fourth-guiyang-cause`<br>`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`<br>`tenthBattleVictoryPages/tenth-cao-cao-shadow`<br>`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`<br>`fortyEighthBattleIntroPages/forty-eighth-zhuge-amnesty`<br>`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`<br>`twentySeventhBattleIntroPages/twenty-seventh-gate-warning`<br>`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`<br>`twentyFourthBattleIntroPages/twenty-fourth-yi-ji-rumor`<br>`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`<br>`thirtySixthBattleIntroPages/thirty-sixth-dingjun-mountain`<br>`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`<br>`twentyThirdBattleVictoryPages/twenty-third-victory-southern-road`<br>`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`<br>`fortySeventhBattleVictoryPages/forty-seventh-next-nanman`<br>`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`<br>`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 |

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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() ?? [];

View File

@@ -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);
}
}

View File

@@ -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',

View File

@@ -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;

View File

@@ -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<Record<string, SortieHistoryBattleLike>>
| 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<string, UnitHistoryAccumulator>(
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<string>();
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;
}

View File

@@ -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<StoryBackgroundSequencePlan | undefined>(
(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) {

View File

@@ -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,

View File

@@ -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<string, unknown>;
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;
}