feat: enrich campaign reward and roster visuals
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() ?? [];
|
||||
|
||||
124
scripts/verify-sortie-roster-history.mjs
Normal file
124
scripts/verify-sortie-roster-history.mjs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
257
src/game/data/sortieRosterHistory.ts
Normal file
257
src/game/data/sortieRosterHistory.ts
Normal 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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user