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