diff --git a/docs/roadmap.md b/docs/roadmap.md
index 0c1c786..b34664b 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -96,19 +96,21 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of
- Post-Hanzhong-rain camp events with Li Yan, Huang Quan, Jiang Wei, Wang Ping, Zhuge Liang, Wei Yan, and Ma Liang focusing on rain-delay ledgers, return-first road markings, restrained counterattack limits, and the next northern-front recalculation
- Sixty-second battle Qishan renewed offensive route, where the preserved Hanzhong and Wudu/Yinping supply roads turn back into an attack decision, forcing nine recommended officers into seven slots across a 124x106 battlefield while Zhuge Liang pressures Sima Yi's delay line, Zhang He's pursuit, Guo Huai's flank block, and Lucheng-front villages
- Post-Qishan-renewed camp events with Jiang Wei, Wei Yan, Zhao Yun, Wang Ping, Huang Quan, Ma Liang, Li Yan, and Ma Dai focusing on breakthrough limits, return markers, village proclamations, and the next deeper northern-front calculation
+- Sixty-third battle Lucheng pursuit route, sixty-fourth battle Weishui camps route, and sixty-fifth battle Weishui north-bank pressure route extend the northern-front push into Sima Yi's delay line while keeping Zhuge Liang-led seven-officer sortie tradeoffs active
+- Sixty-sixth battle Wuzhang finale closes the current campaign with a 132x114 final battlefield, Sima Yi/Zhang He/Guo Huai objectives, Wuzhang aftermath camp events, Jiang Wei inheritance framing, and final epilogue transition into the ending scene
+- Unit spritesheet loading is now deferred until each battle needs the selected texture set, while story backgrounds and battle maps continue to lazy-load per scene
- Camp progress timeline tab that summarizes Liu Bei's long campaign arc, completed battles, current chapter, latest battle, and next major chapter
- Tactical sortie preparation panel with battle-specific sortie limits, required/excluded/recommended officers, expanding recruited-roster selection pressure, class role, named equipment, core stats, bond partner, next-map terrain suitability, focused officer analysis, deployment preview, formation roles, active bond count, recruited-officer count, reserve roster summary, and readiness advice for each deployable officer
- Officer collection support in camp, including full roster status, selected/reserve/recommended markers, selectable reserve drill focus, and post-battle reserve training growth for benched officers
- Treasure equipment effects wired into battle previews and resolution, including named weapon damage bonuses, defensive treasure mitigation, support recovery bonuses, turn-start charm recovery, and equipment-specific growth bonuses
-- Flow verification script from title through the Wudu/Yinping commanderies, Hanzhong rainy-pass defense, Qishan renewed offensive, and post-renewed-offensive camp, with Jiang Wei's first selectable sortie, King of Hanzhong council milestone, Shu-Han foundation milestone, recruit sortie selection, Liu Biao visit rewards, Zhuge Liang recruitment, Bowang/Changban/Jiangdong/Red Cliffs/Jing Province/Guiyang/Wuling/Changsha/Yi Province/Fu Pass/Luo Castle/Luofeng/Luo main gate/Mianzhu/Chengdu/Jiameng/Yangping/Dingjun/Hanzhong/Jing defense/Fan Castle/Han River/Fan siege/Jing rear/Gongan collapse/Maicheng isolation/Yiling vanguard/Yiling fire/Baidi entrustment/Nanzhong stabilization/Meng Huo pacification camp states, campaign timeline state, and camp save state
+- Flow verification script now runs from title through Wuzhang finale, final epilogue, ending-complete save state, and title continue back into the ending screen, while also checking deferred unit texture loading
## Next Steps
-1. Build the next northern-front step after Qishan renewed offensive, turning the pressure on Sima Yi's delay line into the next deeper northern campaign choice without treating the successful push as an ending
-2. Expand post-Baidi and post-Nanzhong camp consequences with grief checks, recovery policy choices, village reassurance, captive handling, and regency decisions that affect sortie recommendations
-3. Expand reserve planning into individual officer assignments, including targeted class practice, paired bond training, and camp task slots
-4. Add a dedicated treasure/equipment management view so players can compare special effects and growth
-5. Keep expanding scenarios through the long campaign instead of treating the current slice as an ending
+1. Polish the final ending presentation with a dedicated Wuzhang/Jiang Wei visual if a new original story background is commissioned
+2. Expand reserve planning into individual officer assignments, including targeted class practice, paired bond training, and camp task slots
+3. Add a dedicated treasure/equipment management view so players can compare special effects and growth
+4. Consider a later "season two" Jiang Wei northern-campaign arc only after the Liu Bei/Zhuge Liang campaign ending is stable
## Content Direction
diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs
index 705a9cd..c378094 100644
--- a/scripts/verify-flow.mjs
+++ b/scripts/verify-flow.mjs
@@ -53,6 +53,10 @@ try {
const activeScenes = window.__HEROS_GAME__?.scene.getScenes(true) ?? [];
return activeScenes.some((scene) => scene.scene.key === 'BattleScene');
});
+ await page.waitForFunction(() => {
+ const state = window.__HEROS_DEBUG__?.battle();
+ return state?.scene === 'BattleScene' && state?.phase === 'idle' && state?.mapBackgroundReady === true;
+ });
await page.keyboard.press('F9');
await page.waitForTimeout(100);
@@ -96,64 +100,24 @@ try {
throw new Error(`Expected live leader objective to start active: ${JSON.stringify(result.battleState.objectives)}`);
}
- const actionTexturesLoaded = await page.evaluate(() => {
+ const unitTextureLoadState = await page.evaluate(() => {
const textures = window.__HEROS_GAME__?.textures;
- return [
- 'unit-liu-bei-actions',
- 'unit-guan-yu-actions',
- 'unit-zhang-fei-actions',
- 'unit-rebel-actions',
- 'unit-rebel-archer-actions',
- 'unit-rebel-cavalry-actions',
- 'unit-rebel-leader-actions',
- 'unit-shu-cavalry',
- 'unit-shu-cavalry-actions',
- 'unit-wei-infantry',
- 'unit-wei-infantry-actions',
- 'unit-wu-archer',
- 'unit-wu-archer-actions',
- 'unit-nanman-officer',
- 'unit-nanman-officer-actions',
- 'unit-zhao-yun',
- 'unit-zhao-yun-actions',
- 'unit-zhuge-liang',
+ const battleState = window.__HEROS_DEBUG__?.battle();
+ const missingFirstBattleTextures = (battleState?.units ?? [])
+ .flatMap((unit) => [unit.textureBase, unit.actionTexture])
+ .filter((key, index, keys) => key && keys.indexOf(key) === index)
+ .filter((key) => !textures?.exists(key));
+ const firstBattleTexturesReady = missingFirstBattleTextures.length <= 0;
+ const lateBattleTexturesDeferred = [
'unit-zhuge-liang-actions',
- 'unit-jiang-wei',
'unit-jiang-wei-actions',
- 'unit-sima-yi',
'unit-sima-yi-actions',
- 'unit-ma-liang',
- 'unit-ma-liang-actions',
- 'unit-yi-ji',
- 'unit-yi-ji-actions',
- 'unit-gong-zhi',
- 'unit-gong-zhi-actions',
- 'unit-huang-zhong',
- 'unit-huang-zhong-actions',
- 'unit-wei-yan',
- 'unit-wei-yan-actions',
- 'unit-pang-tong',
- 'unit-pang-tong-actions',
- 'unit-fa-zheng',
- 'unit-fa-zheng-actions',
- 'unit-wu-yi',
- 'unit-wu-yi-actions',
- 'unit-yan-yan',
- 'unit-yan-yan-actions',
- 'unit-li-yan',
- 'unit-li-yan-actions',
- 'unit-huang-quan',
- 'unit-huang-quan-actions',
- 'unit-ma-chao',
- 'unit-ma-chao-actions',
- 'unit-ma-dai',
- 'unit-ma-dai-actions',
- 'unit-wang-ping',
'unit-wang-ping-actions'
- ].every((key) => textures?.exists(key));
+ ].every((key) => !textures?.exists(key));
+ return { firstBattleTexturesReady, lateBattleTexturesDeferred, missingFirstBattleTextures };
});
- if (!actionTexturesLoaded) {
- throw new Error('Expected all unit action textures to be loaded.');
+ if (!unitTextureLoadState.firstBattleTexturesReady || !unitTextureLoadState.lateBattleTexturesDeferred) {
+ throw new Error(`Expected first battle unit textures to load while late campaign textures remain deferred: ${JSON.stringify(unitTextureLoadState)}`);
}
const movementBlockingState = await page.evaluate(() => {
@@ -1844,7 +1808,7 @@ try {
if (
progressTabState?.activeTab !== 'progress' ||
progressTabState.campaignProgress?.completedKnown !== 17 ||
- progressTabState.campaignProgress?.totalKnown !== 65 ||
+ progressTabState.campaignProgress?.totalKnown !== 66 ||
progressTabState.campaignProgress?.activeChapter?.title !== '적벽대전' ||
progressTabState.campaignProgress?.latestBattleTitle !== '융중 방문로' ||
progressTabState.campaignProgress?.nextBattleTitle !== '박망파 매복전'
@@ -1992,7 +1956,7 @@ try {
if (
postBowangProgressState?.activeTab !== 'progress' ||
postBowangProgressState.campaignProgress?.completedKnown !== 18 ||
- postBowangProgressState.campaignProgress?.totalKnown !== 65 ||
+ postBowangProgressState.campaignProgress?.totalKnown !== 66 ||
postBowangProgressState.campaignProgress?.activeChapter?.title !== '적벽대전' ||
postBowangProgressState.campaignProgress?.latestBattleTitle !== '박망파 매복전' ||
postBowangProgressState.campaignProgress?.nextBattleTitle !== '장판파 피난로'
@@ -2127,7 +2091,7 @@ try {
if (
postChangbanProgressState?.activeTab !== 'progress' ||
postChangbanProgressState.campaignProgress?.completedKnown !== 19 ||
- postChangbanProgressState.campaignProgress?.totalKnown !== 65 ||
+ postChangbanProgressState.campaignProgress?.totalKnown !== 66 ||
postChangbanProgressState.campaignProgress?.activeChapter?.title !== '적벽대전' ||
postChangbanProgressState.campaignProgress?.latestBattleTitle !== '장판파 피난로' ||
postChangbanProgressState.campaignProgress?.nextBattleTitle !== '강동 사절로'
@@ -2262,7 +2226,7 @@ try {
if (
postJiangdongProgressState?.activeTab !== 'progress' ||
postJiangdongProgressState.campaignProgress?.completedKnown !== 20 ||
- postJiangdongProgressState.campaignProgress?.totalKnown !== 65 ||
+ postJiangdongProgressState.campaignProgress?.totalKnown !== 66 ||
postJiangdongProgressState.campaignProgress?.activeChapter?.title !== '적벽대전' ||
postJiangdongProgressState.campaignProgress?.latestBattleTitle !== '강동 사절로' ||
postJiangdongProgressState.campaignProgress?.nextBattleTitle !== '적벽 전초전'
@@ -2399,7 +2363,7 @@ try {
if (
postRedCliffsProgressState?.activeTab !== 'progress' ||
postRedCliffsProgressState.campaignProgress?.completedKnown !== 21 ||
- postRedCliffsProgressState.campaignProgress?.totalKnown !== 65 ||
+ postRedCliffsProgressState.campaignProgress?.totalKnown !== 66 ||
postRedCliffsProgressState.campaignProgress?.activeChapter?.title !== '적벽대전' ||
postRedCliffsProgressState.campaignProgress?.latestBattleTitle !== '적벽 전초전' ||
postRedCliffsProgressState.campaignProgress?.nextBattleTitle !== '적벽 화공전'
@@ -2536,7 +2500,7 @@ try {
if (
postFireAttackProgressState?.activeTab !== 'progress' ||
postFireAttackProgressState.campaignProgress?.completedKnown !== 22 ||
- postFireAttackProgressState.campaignProgress?.totalKnown !== 65 ||
+ postFireAttackProgressState.campaignProgress?.totalKnown !== 66 ||
postFireAttackProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' ||
postFireAttackProgressState.campaignProgress?.latestBattleTitle !== '적벽 화공전' ||
postFireAttackProgressState.campaignProgress?.nextBattleTitle !== '형주 남부 진입전'
@@ -2676,7 +2640,7 @@ try {
if (
postJingzhouProgressState?.activeTab !== 'progress' ||
postJingzhouProgressState.campaignProgress?.completedKnown !== 23 ||
- postJingzhouProgressState.campaignProgress?.totalKnown !== 65 ||
+ postJingzhouProgressState.campaignProgress?.totalKnown !== 66 ||
postJingzhouProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' ||
postJingzhouProgressState.campaignProgress?.latestBattleTitle !== '형주 남부 진입전' ||
postJingzhouProgressState.campaignProgress?.nextBattleTitle !== '계양 설득전'
@@ -2815,7 +2779,7 @@ try {
if (
postGuiyangProgressState?.activeTab !== 'progress' ||
postGuiyangProgressState.campaignProgress?.completedKnown !== 24 ||
- postGuiyangProgressState.campaignProgress?.totalKnown !== 65 ||
+ postGuiyangProgressState.campaignProgress?.totalKnown !== 66 ||
postGuiyangProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' ||
postGuiyangProgressState.campaignProgress?.latestBattleTitle !== '계양 설득전' ||
postGuiyangProgressState.campaignProgress?.nextBattleTitle !== '무릉 산길 확보전'
@@ -2955,7 +2919,7 @@ try {
if (
postWulingProgressState?.activeTab !== 'progress' ||
postWulingProgressState.campaignProgress?.completedKnown !== 25 ||
- postWulingProgressState.campaignProgress?.totalKnown !== 65 ||
+ postWulingProgressState.campaignProgress?.totalKnown !== 66 ||
postWulingProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' ||
postWulingProgressState.campaignProgress?.latestBattleTitle !== '무릉 산길 확보전' ||
postWulingProgressState.campaignProgress?.nextBattleTitle !== '장사 노장 대면전'
@@ -3105,7 +3069,7 @@ try {
if (
postChangshaProgressState?.activeTab !== 'progress' ||
postChangshaProgressState.campaignProgress?.completedKnown !== 26 ||
- postChangshaProgressState.campaignProgress?.totalKnown !== 65 ||
+ postChangshaProgressState.campaignProgress?.totalKnown !== 66 ||
postChangshaProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' ||
postChangshaProgressState.campaignProgress?.latestBattleTitle !== '장사 노장 대면전' ||
postChangshaProgressState.campaignProgress?.nextBattleTitle !== '익주 원군로'
@@ -3247,7 +3211,7 @@ try {
if (
postYizhouProgressState?.activeTab !== 'progress' ||
postYizhouProgressState.campaignProgress?.completedKnown !== 27 ||
- postYizhouProgressState.campaignProgress?.totalKnown !== 65 ||
+ postYizhouProgressState.campaignProgress?.totalKnown !== 66 ||
postYizhouProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' ||
postYizhouProgressState.campaignProgress?.latestBattleTitle !== '익주 원군로' ||
postYizhouProgressState.campaignProgress?.nextBattleTitle !== '부수관 진입전'
@@ -3391,7 +3355,7 @@ try {
if (
postFuPassProgressState?.activeTab !== 'progress' ||
postFuPassProgressState.campaignProgress?.completedKnown !== 28 ||
- postFuPassProgressState.campaignProgress?.totalKnown !== 65 ||
+ postFuPassProgressState.campaignProgress?.totalKnown !== 66 ||
postFuPassProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' ||
postFuPassProgressState.campaignProgress?.latestBattleTitle !== '부수관 진입전' ||
postFuPassProgressState.campaignProgress?.nextBattleTitle !== '낙성 외곽전'
@@ -3540,7 +3504,7 @@ try {
if (
postLuoProgressState?.activeTab !== 'progress' ||
postLuoProgressState.campaignProgress?.completedKnown !== 29 ||
- postLuoProgressState.campaignProgress?.totalKnown !== 65 ||
+ postLuoProgressState.campaignProgress?.totalKnown !== 66 ||
postLuoProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' ||
postLuoProgressState.campaignProgress?.latestBattleTitle !== '낙성 외곽전' ||
postLuoProgressState.campaignProgress?.nextBattleTitle !== '낙봉파 매복전'
@@ -3683,7 +3647,7 @@ try {
if (
postLuofengProgressState?.activeTab !== 'progress' ||
postLuofengProgressState.campaignProgress?.completedKnown !== 30 ||
- postLuofengProgressState.campaignProgress?.totalKnown !== 65 ||
+ postLuofengProgressState.campaignProgress?.totalKnown !== 66 ||
postLuofengProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' ||
postLuofengProgressState.campaignProgress?.latestBattleTitle !== '낙봉파 매복전' ||
postLuofengProgressState.campaignProgress?.nextBattleTitle !== '낙성 본성 공략'
@@ -3837,7 +3801,7 @@ try {
if (
postLuoMainProgressState?.activeTab !== 'progress' ||
postLuoMainProgressState.campaignProgress?.completedKnown !== 31 ||
- postLuoMainProgressState.campaignProgress?.totalKnown !== 65 ||
+ postLuoMainProgressState.campaignProgress?.totalKnown !== 66 ||
postLuoMainProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' ||
postLuoMainProgressState.campaignProgress?.latestBattleTitle !== '낙성 본성 공략' ||
postLuoMainProgressState.campaignProgress?.nextBattleTitle !== '면죽관 압박전'
@@ -3987,7 +3951,7 @@ try {
if (
postMianzhuProgressState?.activeTab !== 'progress' ||
postMianzhuProgressState.campaignProgress?.completedKnown !== 32 ||
- postMianzhuProgressState.campaignProgress?.totalKnown !== 65 ||
+ postMianzhuProgressState.campaignProgress?.totalKnown !== 66 ||
postMianzhuProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' ||
postMianzhuProgressState.campaignProgress?.latestBattleTitle !== '면죽관 압박전' ||
postMianzhuProgressState.campaignProgress?.nextBattleTitle !== '성도 항복 권고전'
@@ -4138,7 +4102,7 @@ try {
if (
postChengduProgressState?.activeTab !== 'progress' ||
postChengduProgressState.campaignProgress?.completedKnown !== 33 ||
- postChengduProgressState.campaignProgress?.totalKnown !== 65 ||
+ postChengduProgressState.campaignProgress?.totalKnown !== 66 ||
postChengduProgressState.campaignProgress?.activeChapter?.title !== '촉한 건국' ||
postChengduProgressState.campaignProgress?.latestBattleTitle !== '성도 항복 권고전' ||
postChengduProgressState.campaignProgress?.nextBattleTitle !== '가맹관 마초 대면전'
@@ -4294,7 +4258,7 @@ try {
if (
postJiamengProgressState?.activeTab !== 'progress' ||
postJiamengProgressState.campaignProgress?.completedKnown !== 34 ||
- postJiamengProgressState.campaignProgress?.totalKnown !== 65 ||
+ postJiamengProgressState.campaignProgress?.totalKnown !== 66 ||
postJiamengProgressState.campaignProgress?.activeChapter?.title !== '촉한 건국' ||
postJiamengProgressState.campaignProgress?.latestBattleTitle !== '가맹관 마초 대면전' ||
postJiamengProgressState.campaignProgress?.nextBattleTitle !== '양평관 정찰전'
@@ -4447,7 +4411,7 @@ try {
if (
postYangpingProgressState?.activeTab !== 'progress' ||
postYangpingProgressState.campaignProgress?.completedKnown !== 35 ||
- postYangpingProgressState.campaignProgress?.totalKnown !== 65 ||
+ postYangpingProgressState.campaignProgress?.totalKnown !== 66 ||
postYangpingProgressState.campaignProgress?.activeChapter?.title !== '촉한 건국' ||
postYangpingProgressState.campaignProgress?.latestBattleTitle !== '양평관 정찰전' ||
postYangpingProgressState.campaignProgress?.nextBattleTitle !== '정군산 전초전'
@@ -4606,7 +4570,7 @@ try {
if (
postDingjunProgressState?.activeTab !== 'progress' ||
postDingjunProgressState.campaignProgress?.completedKnown !== 36 ||
- postDingjunProgressState.campaignProgress?.totalKnown !== 65 ||
+ postDingjunProgressState.campaignProgress?.totalKnown !== 66 ||
postDingjunProgressState.campaignProgress?.activeChapter?.title !== '촉한 건국' ||
postDingjunProgressState.campaignProgress?.latestBattleTitle !== '정군산 전초전' ||
postDingjunProgressState.campaignProgress?.nextBattleTitle !== '한중 결전'
@@ -4756,7 +4720,7 @@ try {
if (
postHanzhongProgressState?.activeTab !== 'progress' ||
postHanzhongProgressState.campaignProgress?.completedKnown !== 37 ||
- postHanzhongProgressState.campaignProgress?.totalKnown !== 65 ||
+ postHanzhongProgressState.campaignProgress?.totalKnown !== 66 ||
postHanzhongProgressState.campaignProgress?.activeChapter?.title !== '촉한 건국' ||
postHanzhongProgressState.campaignProgress?.latestBattleTitle !== '한중 결전' ||
postHanzhongProgressState.campaignProgress?.nextBattleTitle !== '한중왕 즉위 준비'
@@ -4814,7 +4778,7 @@ try {
if (
postKingCouncilProgressState?.activeTab !== 'progress' ||
postKingCouncilProgressState.campaignProgress?.completedKnown !== 37 ||
- postKingCouncilProgressState.campaignProgress?.totalKnown !== 65 ||
+ postKingCouncilProgressState.campaignProgress?.totalKnown !== 66 ||
postKingCouncilProgressState.campaignProgress?.latestBattleTitle !== '한중 결전' ||
postKingCouncilProgressState.campaignProgress?.nextBattleTitle !== '촉한 건국 선포'
) {
@@ -4871,7 +4835,7 @@ try {
if (
postFoundationProgressState?.activeTab !== 'progress' ||
postFoundationProgressState.campaignProgress?.completedKnown !== 37 ||
- postFoundationProgressState.campaignProgress?.totalKnown !== 65 ||
+ postFoundationProgressState.campaignProgress?.totalKnown !== 66 ||
postFoundationProgressState.campaignProgress?.latestBattleTitle !== '한중 결전' ||
postFoundationProgressState.campaignProgress?.nextBattleTitle !== '형주 방위 전초전'
) {
@@ -5019,7 +4983,7 @@ try {
if (
postJingDefenseProgressState?.activeTab !== 'progress' ||
postJingDefenseProgressState.campaignProgress?.completedKnown !== 38 ||
- postJingDefenseProgressState.campaignProgress?.totalKnown !== 65 ||
+ postJingDefenseProgressState.campaignProgress?.totalKnown !== 66 ||
postJingDefenseProgressState.campaignProgress?.latestBattleTitle !== '형주 방위 전초전' ||
postJingDefenseProgressState.campaignProgress?.nextBattleTitle !== '번성 외곽 압박전'
) {
@@ -5169,7 +5133,7 @@ try {
if (
postFanCastleProgressState?.activeTab !== 'progress' ||
postFanCastleProgressState.campaignProgress?.completedKnown !== 39 ||
- postFanCastleProgressState.campaignProgress?.totalKnown !== 65 ||
+ postFanCastleProgressState.campaignProgress?.totalKnown !== 66 ||
postFanCastleProgressState.campaignProgress?.latestBattleTitle !== '번성 외곽 압박전' ||
postFanCastleProgressState.campaignProgress?.nextBattleTitle !== '한수 수공'
) {
@@ -5320,7 +5284,7 @@ try {
if (
postHanRiverProgressState?.activeTab !== 'progress' ||
postHanRiverProgressState.campaignProgress?.completedKnown !== 40 ||
- postHanRiverProgressState.campaignProgress?.totalKnown !== 65 ||
+ postHanRiverProgressState.campaignProgress?.totalKnown !== 66 ||
postHanRiverProgressState.campaignProgress?.latestBattleTitle !== '한수 수공' ||
postHanRiverProgressState.campaignProgress?.nextBattleTitle !== '번성 공성전'
) {
@@ -5471,7 +5435,7 @@ try {
if (
postFanSiegeProgressState?.activeTab !== 'progress' ||
postFanSiegeProgressState.campaignProgress?.completedKnown !== 41 ||
- postFanSiegeProgressState.campaignProgress?.totalKnown !== 65 ||
+ postFanSiegeProgressState.campaignProgress?.totalKnown !== 66 ||
postFanSiegeProgressState.campaignProgress?.latestBattleTitle !== '번성 공성전' ||
postFanSiegeProgressState.campaignProgress?.nextBattleTitle !== '강릉 나루 경계전'
) {
@@ -5630,7 +5594,7 @@ try {
if (
postJingRearProgressState?.activeTab !== 'progress' ||
postJingRearProgressState.campaignProgress?.completedKnown !== 42 ||
- postJingRearProgressState.campaignProgress?.totalKnown !== 65 ||
+ postJingRearProgressState.campaignProgress?.totalKnown !== 66 ||
postJingRearProgressState.campaignProgress?.latestBattleTitle !== '강릉 나루 경계전' ||
postJingRearProgressState.campaignProgress?.nextBattleTitle !== '공안 성문 변고'
) {
@@ -5782,7 +5746,7 @@ try {
if (
postGonganProgressState?.activeTab !== 'progress' ||
postGonganProgressState.campaignProgress?.completedKnown !== 43 ||
- postGonganProgressState.campaignProgress?.totalKnown !== 65 ||
+ postGonganProgressState.campaignProgress?.totalKnown !== 66 ||
postGonganProgressState.campaignProgress?.latestBattleTitle !== '공안 성문 변고' ||
postGonganProgressState.campaignProgress?.nextBattleTitle !== '맥성 고립전'
) {
@@ -5937,7 +5901,7 @@ try {
if (
postMaichengProgressState?.activeTab !== 'progress' ||
postMaichengProgressState.campaignProgress?.completedKnown !== 44 ||
- postMaichengProgressState.campaignProgress?.totalKnown !== 65 ||
+ postMaichengProgressState.campaignProgress?.totalKnown !== 66 ||
postMaichengProgressState.campaignProgress?.latestBattleTitle !== '맥성 고립전' ||
postMaichengProgressState.campaignProgress?.nextBattleTitle !== '이릉 진격로'
) {
@@ -6091,7 +6055,7 @@ try {
if (
postYilingProgressState?.activeTab !== 'progress' ||
postYilingProgressState.campaignProgress?.completedKnown !== 45 ||
- postYilingProgressState.campaignProgress?.totalKnown !== 65 ||
+ postYilingProgressState.campaignProgress?.totalKnown !== 66 ||
postYilingProgressState.campaignProgress?.latestBattleTitle !== '이릉 진격로' ||
postYilingProgressState.campaignProgress?.nextBattleTitle !== '이릉 화공전'
) {
@@ -6246,7 +6210,7 @@ try {
if (
postYilingFireProgressState?.activeTab !== 'progress' ||
postYilingFireProgressState.campaignProgress?.completedKnown !== 46 ||
- postYilingFireProgressState.campaignProgress?.totalKnown !== 65 ||
+ postYilingFireProgressState.campaignProgress?.totalKnown !== 66 ||
postYilingFireProgressState.campaignProgress?.latestBattleTitle !== '이릉 화공전' ||
postYilingFireProgressState.campaignProgress?.nextBattleTitle !== '백제성 유탁'
) {
@@ -6309,7 +6273,7 @@ try {
if (
postBaidiProgressState?.activeTab !== 'progress' ||
postBaidiProgressState.campaignProgress?.completedKnown !== 46 ||
- postBaidiProgressState.campaignProgress?.totalKnown !== 65 ||
+ postBaidiProgressState.campaignProgress?.totalKnown !== 66 ||
postBaidiProgressState.campaignProgress?.latestBattleTitle !== '이릉 화공전' ||
postBaidiProgressState.campaignProgress?.nextBattleTitle !== '남중 안정로'
) {
@@ -6467,7 +6431,7 @@ try {
if (
postNanzhongProgressState?.activeTab !== 'progress' ||
postNanzhongProgressState.campaignProgress?.completedKnown !== 47 ||
- postNanzhongProgressState.campaignProgress?.totalKnown !== 65 ||
+ postNanzhongProgressState.campaignProgress?.totalKnown !== 66 ||
postNanzhongProgressState.campaignProgress?.latestBattleTitle !== '남중 안정로' ||
postNanzhongProgressState.campaignProgress?.nextBattleTitle !== '맹획 본대전'
) {
@@ -6626,7 +6590,7 @@ try {
if (
postMengHuoProgressState?.activeTab !== 'progress' ||
postMengHuoProgressState.campaignProgress?.completedKnown !== 48 ||
- postMengHuoProgressState.campaignProgress?.totalKnown !== 65 ||
+ postMengHuoProgressState.campaignProgress?.totalKnown !== 66 ||
postMengHuoProgressState.campaignProgress?.latestBattleTitle !== '맹획 본대전' ||
postMengHuoProgressState.campaignProgress?.nextBattleTitle !== '칠종칠금 2차전'
) {
@@ -6785,7 +6749,7 @@ try {
if (
postSecondCaptureProgressState?.activeTab !== 'progress' ||
postSecondCaptureProgressState.campaignProgress?.completedKnown !== 49 ||
- postSecondCaptureProgressState.campaignProgress?.totalKnown !== 65 ||
+ postSecondCaptureProgressState.campaignProgress?.totalKnown !== 66 ||
postSecondCaptureProgressState.campaignProgress?.latestBattleTitle !== '칠종칠금 2차전' ||
postSecondCaptureProgressState.campaignProgress?.nextBattleTitle !== '칠종칠금 3차전'
) {
@@ -6947,7 +6911,7 @@ try {
if (
postThirdCaptureProgressState?.activeTab !== 'progress' ||
postThirdCaptureProgressState.campaignProgress?.completedKnown !== 50 ||
- postThirdCaptureProgressState.campaignProgress?.totalKnown !== 65 ||
+ postThirdCaptureProgressState.campaignProgress?.totalKnown !== 66 ||
postThirdCaptureProgressState.campaignProgress?.latestBattleTitle !== '칠종칠금 3차전' ||
postThirdCaptureProgressState.campaignProgress?.nextBattleTitle !== '칠종칠금 4차전'
) {
@@ -7110,7 +7074,7 @@ try {
if (
postFourthCaptureProgressState?.activeTab !== 'progress' ||
postFourthCaptureProgressState.campaignProgress?.completedKnown !== 51 ||
- postFourthCaptureProgressState.campaignProgress?.totalKnown !== 65 ||
+ postFourthCaptureProgressState.campaignProgress?.totalKnown !== 66 ||
postFourthCaptureProgressState.campaignProgress?.latestBattleTitle !== '칠종칠금 4차전' ||
postFourthCaptureProgressState.campaignProgress?.nextBattleTitle !== '칠종칠금 5차전'
) {
@@ -7273,7 +7237,7 @@ try {
if (
postFifthCaptureProgressState?.activeTab !== 'progress' ||
postFifthCaptureProgressState.campaignProgress?.completedKnown !== 52 ||
- postFifthCaptureProgressState.campaignProgress?.totalKnown !== 65 ||
+ postFifthCaptureProgressState.campaignProgress?.totalKnown !== 66 ||
postFifthCaptureProgressState.campaignProgress?.latestBattleTitle !== '칠종칠금 5차전' ||
postFifthCaptureProgressState.campaignProgress?.nextBattleTitle !== '칠종칠금 6차전'
) {
@@ -7435,7 +7399,7 @@ try {
if (
postSixthCaptureProgressState?.activeTab !== 'progress' ||
postSixthCaptureProgressState.campaignProgress?.completedKnown !== 53 ||
- postSixthCaptureProgressState.campaignProgress?.totalKnown !== 65 ||
+ postSixthCaptureProgressState.campaignProgress?.totalKnown !== 66 ||
postSixthCaptureProgressState.campaignProgress?.latestBattleTitle !== '칠종칠금 6차전' ||
postSixthCaptureProgressState.campaignProgress?.nextBattleTitle !== '칠종칠금 7차전'
) {
@@ -7598,7 +7562,7 @@ try {
if (
postFinalCaptureProgressState?.activeTab !== 'progress' ||
postFinalCaptureProgressState.campaignProgress?.completedKnown !== 54 ||
- postFinalCaptureProgressState.campaignProgress?.totalKnown !== 65 ||
+ postFinalCaptureProgressState.campaignProgress?.totalKnown !== 66 ||
postFinalCaptureProgressState.campaignProgress?.activeChapter?.id !== 'northern-campaign' ||
postFinalCaptureProgressState.campaignProgress?.latestBattleTitle !== '칠종칠금 7차전' ||
!String(postFinalCaptureProgressState.campaignProgress?.nextBattleTitle ?? '').includes('북벌 준비')
@@ -7668,7 +7632,7 @@ try {
if (
postNorthernPrepProgressState?.activeTab !== 'progress' ||
postNorthernPrepProgressState.campaignProgress?.completedKnown !== 54 ||
- postNorthernPrepProgressState.campaignProgress?.totalKnown !== 65 ||
+ postNorthernPrepProgressState.campaignProgress?.totalKnown !== 66 ||
postNorthernPrepProgressState.campaignProgress?.activeChapter?.id !== 'northern-campaign' ||
postNorthernPrepProgressState.campaignProgress?.latestBattleTitle !== '칠종칠금 7차전' ||
postNorthernPrepProgressState.campaignProgress?.nextBattleTitle !== '제1차 북벌 출진로'
@@ -7815,7 +7779,7 @@ try {
if (
postFiftyFifthProgressState?.activeTab !== 'progress' ||
postFiftyFifthProgressState.campaignProgress?.completedKnown !== 55 ||
- postFiftyFifthProgressState.campaignProgress?.totalKnown !== 65 ||
+ postFiftyFifthProgressState.campaignProgress?.totalKnown !== 66 ||
postFiftyFifthProgressState.campaignProgress?.activeChapter?.id !== 'northern-campaign' ||
postFiftyFifthProgressState.campaignProgress?.latestBattleTitle !== '제1차 북벌 출진로' ||
postFiftyFifthProgressState.campaignProgress?.nextBattleTitle !== '천수 진군로'
@@ -7962,7 +7926,7 @@ try {
if (
postFiftySixthProgressState?.activeTab !== 'progress' ||
postFiftySixthProgressState.campaignProgress?.completedKnown !== 56 ||
- postFiftySixthProgressState.campaignProgress?.totalKnown !== 65 ||
+ postFiftySixthProgressState.campaignProgress?.totalKnown !== 66 ||
postFiftySixthProgressState.campaignProgress?.activeChapter?.id !== 'northern-campaign' ||
postFiftySixthProgressState.campaignProgress?.latestBattleTitle !== '천수 진군로' ||
postFiftySixthProgressState.campaignProgress?.nextBattleTitle !== '가정 배치 위기'
@@ -8114,7 +8078,7 @@ try {
if (
postFiftySeventhProgressState?.activeTab !== 'progress' ||
postFiftySeventhProgressState.campaignProgress?.completedKnown !== 57 ||
- postFiftySeventhProgressState.campaignProgress?.totalKnown !== 65 ||
+ postFiftySeventhProgressState.campaignProgress?.totalKnown !== 66 ||
postFiftySeventhProgressState.campaignProgress?.activeChapter?.id !== 'northern-campaign' ||
postFiftySeventhProgressState.campaignProgress?.latestBattleTitle !== '가정 배치 위기' ||
postFiftySeventhProgressState.campaignProgress?.nextBattleTitle !== '기산 후퇴로'
@@ -8262,7 +8226,7 @@ try {
if (
postFiftyEighthProgressState?.activeTab !== 'progress' ||
postFiftyEighthProgressState.campaignProgress?.completedKnown !== 58 ||
- postFiftyEighthProgressState.campaignProgress?.totalKnown !== 65 ||
+ postFiftyEighthProgressState.campaignProgress?.totalKnown !== 66 ||
postFiftyEighthProgressState.campaignProgress?.activeChapter?.id !== 'northern-campaign' ||
postFiftyEighthProgressState.campaignProgress?.latestBattleTitle !== '기산 후퇴로' ||
postFiftyEighthProgressState.campaignProgress?.nextBattleTitle !== '진창 공성전'
@@ -8410,7 +8374,7 @@ try {
if (
postFiftyNinthProgressState?.activeTab !== 'progress' ||
postFiftyNinthProgressState.campaignProgress?.completedKnown !== 59 ||
- postFiftyNinthProgressState.campaignProgress?.totalKnown !== 65 ||
+ postFiftyNinthProgressState.campaignProgress?.totalKnown !== 66 ||
postFiftyNinthProgressState.campaignProgress?.activeChapter?.id !== 'northern-campaign' ||
postFiftyNinthProgressState.campaignProgress?.latestBattleTitle !== '진창 공성전' ||
postFiftyNinthProgressState.campaignProgress?.nextBattleTitle !== '무도·음평 확보전'
@@ -8564,7 +8528,7 @@ try {
if (
postSixtiethProgressState?.activeTab !== 'progress' ||
postSixtiethProgressState.campaignProgress?.completedKnown !== 60 ||
- postSixtiethProgressState.campaignProgress?.totalKnown !== 65 ||
+ postSixtiethProgressState.campaignProgress?.totalKnown !== 66 ||
postSixtiethProgressState.campaignProgress?.activeChapter?.id !== 'northern-campaign' ||
postSixtiethProgressState.campaignProgress?.latestBattleTitle !== '무도·음평 확보전' ||
postSixtiethProgressState.campaignProgress?.nextBattleTitle !== '한중 우로 방어전'
@@ -8721,7 +8685,7 @@ try {
if (
postSixtyFirstProgressState?.activeTab !== 'progress' ||
postSixtyFirstProgressState.campaignProgress?.completedKnown !== 61 ||
- postSixtyFirstProgressState.campaignProgress?.totalKnown !== 65 ||
+ postSixtyFirstProgressState.campaignProgress?.totalKnown !== 66 ||
postSixtyFirstProgressState.campaignProgress?.activeChapter?.id !== 'northern-campaign' ||
postSixtyFirstProgressState.campaignProgress?.latestBattleTitle !== '한중 우로 방어전' ||
postSixtyFirstProgressState.campaignProgress?.nextBattleTitle !== '기산 재출정전'
@@ -8881,7 +8845,7 @@ try {
if (
postSixtySecondProgressState?.activeTab !== 'progress' ||
postSixtySecondProgressState.campaignProgress?.completedKnown !== 62 ||
- postSixtySecondProgressState.campaignProgress?.totalKnown !== 65 ||
+ postSixtySecondProgressState.campaignProgress?.totalKnown !== 66 ||
postSixtySecondProgressState.campaignProgress?.activeChapter?.id !== 'northern-campaign' ||
postSixtySecondProgressState.campaignProgress?.latestBattleTitle !== '기산 재출정전' ||
postSixtySecondProgressState.campaignProgress?.nextBattleTitle !== '노성 추격전'
@@ -9064,7 +9028,7 @@ try {
if (
postSixtyThirdProgressState?.activeTab !== 'progress' ||
postSixtyThirdProgressState.campaignProgress?.completedKnown !== 63 ||
- postSixtyThirdProgressState.campaignProgress?.totalKnown !== 65 ||
+ postSixtyThirdProgressState.campaignProgress?.totalKnown !== 66 ||
postSixtyThirdProgressState.campaignProgress?.activeChapter?.id !== 'northern-campaign' ||
postSixtyThirdProgressState.campaignProgress?.latestBattleTitle !== '노성 추격전' ||
postSixtyThirdProgressState.campaignProgress?.nextBattleTitle !== '위수 진영전'
@@ -9249,7 +9213,7 @@ try {
if (
postSixtyFourthProgressState?.activeTab !== 'progress' ||
postSixtyFourthProgressState.campaignProgress?.completedKnown !== 64 ||
- postSixtyFourthProgressState.campaignProgress?.totalKnown !== 65 ||
+ postSixtyFourthProgressState.campaignProgress?.totalKnown !== 66 ||
postSixtyFourthProgressState.campaignProgress?.activeChapter?.id !== 'northern-campaign' ||
postSixtyFourthProgressState.campaignProgress?.latestBattleTitle !== '위수 진영전' ||
postSixtyFourthProgressState.campaignProgress?.nextBattleTitle !== '위수 북안 압박전'
@@ -9444,17 +9408,147 @@ try {
await page.waitForTimeout(180);
const postSixtyFifthProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
if (
- postSixtyFifthProgressState?.activeTab !== 'progress' ||
postSixtyFifthProgressState.campaignProgress?.completedKnown !== 65 ||
- postSixtyFifthProgressState.campaignProgress?.totalKnown !== 65 ||
+ postSixtyFifthProgressState.campaignProgress?.totalKnown !== 66 ||
postSixtyFifthProgressState.campaignProgress?.activeChapter?.id !== 'northern-campaign' ||
postSixtyFifthProgressState.campaignProgress?.latestBattleTitle !== '위수 북안 압박전' ||
- postSixtyFifthProgressState.campaignProgress?.nextBattleTitle !== '북벌의 끝과 남은 뜻'
+ postSixtyFifthProgressState.campaignProgress?.nextBattleTitle !== '오장원 최종전'
) {
- throw new Error(`Expected post-sixty-fifth progress tab to complete Weishui north bank pressure and expose the final epilogue: ${JSON.stringify(postSixtyFifthProgressState?.campaignProgress)}`);
+ throw new Error(`Expected post-sixty-fifth progress tab to complete Weishui north bank pressure and point toward the Wuzhang finale: ${JSON.stringify(postSixtyFifthProgressState?.campaignProgress)}`);
}
await page.screenshot({ path: 'dist/verification-post-sixty-fifth-progress.png', fullPage: true });
+ await page.mouse.click(1160, 38);
+ await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortieVisible === true);
+ const wuzhangPriorityUnits = ['zhuge-liang', 'jiang-wei', 'wang-ping', 'ma-dai', 'huang-quan', 'li-yan', 'wei-yan'];
+ const wuzhangSortieReadyState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
+ if (
+ !wuzhangPriorityUnits.every((unitId) =>
+ wuzhangSortieReadyState.sortieRoster?.some((unit) => unit.id === unitId && unit.selected)
+ ) ||
+ !wuzhangSortieReadyState.sortieRoster?.some((unit) => unit.id === 'zhuge-liang' && unit.selected && unit.required) ||
+ wuzhangSortieReadyState.sortieRoster?.some((unit) => unit.id === 'liu-bei') ||
+ wuzhangSortieReadyState.sortiePlan?.selectedCount !== 7 ||
+ wuzhangSortieReadyState.sortiePlan?.recommendedSelectedCount < 7 ||
+ wuzhangSortieReadyState.sortiePlan?.recommendedTotal !== 9
+ ) {
+ throw new Error(`Expected Wuzhang finale sortie to keep Zhuge Liang plus inherited northern campaign core officers selected: ${JSON.stringify(wuzhangSortieReadyState)}`);
+ }
+ await page.screenshot({ path: 'dist/verification-sixty-sixth-sortie.png', fullPage: true });
+
+ await page.mouse.click(1068, 646);
+ await waitForStoryReady(page);
+ for (let i = 0; i < 12; i += 1) {
+ const storyState = await page.evaluate(() =>
+ window.__HEROS_GAME__?.scene.getScene('StoryScene')?.getDebugState?.()
+ );
+ if (storyState?.currentPageId?.startsWith('sixty-sixth-') && storyState?.backgroundReady === true) {
+ break;
+ }
+ await page.keyboard.press('Space');
+ await page.waitForTimeout(320);
+ }
+ const wuzhangStoryState = await page.evaluate(() =>
+ window.__HEROS_GAME__?.scene.getScene('StoryScene')?.getDebugState?.()
+ );
+ if (
+ !wuzhangStoryState?.currentPageId?.startsWith('sixty-sixth-') ||
+ !['story-weishui-northbank', 'story-northern', 'story-hanzhong-rain'].includes(wuzhangStoryState?.requestedBackground) ||
+ wuzhangStoryState?.backgroundReady !== true
+ ) {
+ throw new Error(`Expected Wuzhang finale story to load the new sixty-sixth intro pages and backgrounds: ${JSON.stringify(wuzhangStoryState)}`);
+ }
+ await page.screenshot({ path: 'dist/verification-sixty-sixth-story.png', fullPage: true });
+
+ for (let i = 0; i < 50; i += 1) {
+ const enteredSixtySixthBattle = await page.evaluate(() => {
+ const state = window.__HEROS_DEBUG__?.battle();
+ return state?.scene === 'BattleScene' && state?.battleId === 'sixty-sixth-battle-wuzhang-final';
+ });
+ if (enteredSixtySixthBattle) {
+ break;
+ }
+ await page.keyboard.press('Space');
+ await page.waitForTimeout(320);
+ }
+ await page.waitForFunction(() => {
+ const state = window.__HEROS_DEBUG__?.battle();
+ return state?.scene === 'BattleScene' && state?.battleId === 'sixty-sixth-battle-wuzhang-final' && state?.battleOutcome === null && state?.phase === 'idle';
+ });
+ await page.screenshot({ path: 'dist/verification-sixty-sixth-battle.png', fullPage: true });
+
+ const sixtySixthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
+ const sixtySixthEnemies = sixtySixthBattleState.units.filter((unit) => unit.faction === 'enemy');
+ const sixtySixthAllies = sixtySixthBattleState.units.filter((unit) => unit.faction === 'ally');
+ if (
+ sixtySixthBattleState.camera?.mapWidth !== 132 ||
+ sixtySixthBattleState.camera?.mapHeight !== 114 ||
+ sixtySixthBattleState.mapTextureKey !== 'battle-map-sixty-sixth' ||
+ sixtySixthBattleState.mapTextureReady !== true ||
+ sixtySixthBattleState.mapBackgroundReady !== true ||
+ sixtySixthBattleState.victoryConditionLabel !== '오장원 본영 방어와 장부 계승 확보' ||
+ !sixtySixthBattleState.objectives?.some((objective) => objective.id === 'hold-wuzhang-main-camp' && objective.label === '사마의 오장원 본대 격파') ||
+ !sixtySixthBattleState.objectives?.some((objective) => objective.id === 'stop-zhang-he-final-pursuit' && objective.label === '장합 최종 추격대 저지') ||
+ !sixtySixthBattleState.objectives?.some((objective) => objective.id === 'silence-guo-huai-wuzhang-beacons' && objective.label === '곽회 봉화 차단') ||
+ !sixtySixthBattleState.objectives?.some((objective) => objective.id === 'preserve-wuzhang-camps' && objective.label === '오장원 본영 확보') ||
+ !sixtySixthBattleState.objectives?.some((objective) => objective.id === 'protect-returning-villages' && objective.label === '귀환로 마을 보호') ||
+ sixtySixthEnemies.length < 40 ||
+ !sixtySixthEnemies.some((unit) => unit.id === 'northern-twelfth-leader-sima-yi' && unit.combatPortraitKey === 'portrait-sima-yi' && unit.textureBase === 'unit-sima-yi') ||
+ !sixtySixthEnemies.some((unit) => unit.id === 'northern-twelfth-officer-zhang-he') ||
+ !sixtySixthEnemies.some((unit) => unit.id === 'northern-twelfth-officer-guo-huai') ||
+ !wuzhangPriorityUnits.every((unitId) => sixtySixthAllies.some((unit) => unit.id === unitId)) ||
+ !unitUsesTexture(sixtySixthBattleState, 'jiang-wei', 'unit-jiang-wei') ||
+ !unitUsesTexture(sixtySixthBattleState, 'ma-dai', 'unit-ma-dai') ||
+ !unitUsesTexture(sixtySixthBattleState, 'wang-ping', 'unit-wang-ping') ||
+ !unitUsesTexture(sixtySixthBattleState, 'huang-quan', 'unit-huang-quan') ||
+ !unitUsesTexture(sixtySixthBattleState, 'li-yan', 'unit-li-yan') ||
+ sixtySixthAllies.some((unit) => unit.id === 'liu-bei')
+ ) {
+ throw new Error(`Expected Wuzhang finale battle to use the new map, Sima Yi/Zhang He/Guo Huai objectives, and inherited northern campaign officers: ${JSON.stringify(sixtySixthBattleState)}`);
+ }
+
+ await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory'));
+ await page.waitForFunction(() => {
+ const state = window.__HEROS_DEBUG__?.battle();
+ return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true;
+ });
+
+ await page.mouse.click(738, 642);
+ await page.waitForFunction(() => {
+ const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
+ return activeScenes.includes('CampScene');
+ });
+
+ const sixtySixthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
+ if (
+ sixtySixthCampState?.campBattleId !== 'sixty-sixth-battle-wuzhang-final' ||
+ sixtySixthCampState.campaign?.step !== 'sixty-sixth-camp' ||
+ sixtySixthCampState.campTitle !== '오장원 최종전 후 군영' ||
+ sixtySixthCampState.availableDialogueIds?.length !== 2 ||
+ !sixtySixthCampState.availableDialogueIds.every((id) => id.startsWith('northern-twelfth-')) ||
+ sixtySixthCampState.availableVisitIds?.length !== 1 ||
+ !sixtySixthCampState.availableVisitIds.every((id) => id.startsWith('northern-twelfth-')) ||
+ sixtySixthCampState.rosterCollection?.total < 22 ||
+ !sixtySixthCampState.report?.bonds?.some((bond) => bond.id === 'zhuge-liang__jiang-wei_northern-twelfth')
+ ) {
+ throw new Error(`Expected Wuzhang finale aftermath camp to expose twelfth northern campaign events and bonds: ${JSON.stringify(sixtySixthCampState)}`);
+ }
+ await page.screenshot({ path: 'dist/verification-sixty-sixth-camp.png', fullPage: true });
+
+ await page.mouse.click(966, 38);
+ await page.waitForTimeout(180);
+ const postSixtySixthProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
+ if (
+ postSixtySixthProgressState.campaignProgress?.completedKnown !== 66 ||
+ postSixtySixthProgressState.campaignProgress?.totalKnown !== 66 ||
+ postSixtySixthProgressState.campaignProgress?.activeChapter?.id !== 'northern-campaign' ||
+ postSixtySixthProgressState.campaignProgress?.latestBattleTitle !== '오장원 최종전' ||
+ postSixtySixthProgressState.campaignProgress?.nextBattleTitle !== '북벌의 끝과 남은 뜻'
+ ) {
+ throw new Error(`Expected post-sixty-sixth progress tab to complete Wuzhang finale and expose the final epilogue: ${JSON.stringify(postSixtySixthProgressState?.campaignProgress)}`);
+ }
+ await page.screenshot({ path: 'dist/verification-post-sixty-sixth-progress.png', fullPage: true });
+
await page.mouse.click(1160, 38);
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
@@ -9462,12 +9556,12 @@ try {
});
await page.waitForFunction(() => {
const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.();
- return story?.currentPageId === 'sixty-fifth-victory-delay-line-pressed' && story?.backgroundReady === true;
+ return story?.currentPageId === 'sixty-sixth-victory-wuzhang-held' && story?.backgroundReady === true;
});
await page.screenshot({ path: 'dist/verification-ending-epilogue-story.png', fullPage: true });
await page.evaluate(async () => {
const delay = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms));
- for (let i = 0; i < 16; i += 1) {
+ for (let i = 0; i < 20; i += 1) {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
if (activeScenes.includes('EndingScene')) {
break;
@@ -9488,8 +9582,8 @@ try {
if (
!endingState.activeScenes.includes('EndingScene') ||
endingState.ending?.campaignStep !== 'ending-complete' ||
- endingState.ending?.latestBattleId !== 'sixty-fifth-battle-weishui-northbank' ||
- endingState.ending?.completedBattles !== 65 ||
+ endingState.ending?.latestBattleId !== 'sixty-sixth-battle-wuzhang-final' ||
+ endingState.ending?.completedBattles !== 66 ||
endingState.ending?.endingTitle !== '북벌의 끝과 남은 뜻'
) {
throw new Error(`Expected final epilogue to mark ending-complete and show EndingScene: ${JSON.stringify(endingState)}`);
diff --git a/src/assets/images/battle/sixty-sixth-battle-map.svg b/src/assets/images/battle/sixty-sixth-battle-map.svg
new file mode 100644
index 0000000..539184c
--- /dev/null
+++ b/src/assets/images/battle/sixty-sixth-battle-map.svg
@@ -0,0 +1,77 @@
+
diff --git a/src/game/data/battleMapAssets.ts b/src/game/data/battleMapAssets.ts
index 461283a..6294fc2 100644
--- a/src/game/data/battleMapAssets.ts
+++ b/src/game/data/battleMapAssets.ts
@@ -15,6 +15,7 @@ import sixtySecondBattleMapUrl from '../../assets/images/battle/sixty-second-bat
import sixtyThirdBattleMapUrl from '../../assets/images/battle/sixty-third-battle-map.svg';
import sixtyFourthBattleMapUrl from '../../assets/images/battle/sixty-fourth-battle-map.svg';
import sixtyFifthBattleMapUrl from '../../assets/images/battle/sixty-fifth-battle-map.svg';
+import sixtySixthBattleMapUrl from '../../assets/images/battle/sixty-sixth-battle-map.svg';
import fiftySeventhBattleMapUrl from '../../assets/images/battle/fifty-seventh-battle-map.svg';
import fiftySixthBattleMapUrl from '../../assets/images/battle/fifty-sixth-battle-map.svg';
import fifthBattleMapUrl from '../../assets/images/battle/fifth-battle-map.svg';
@@ -128,6 +129,7 @@ export const battleMapAssets: Record = {
'battle-map-sixty-third': sixtyThirdBattleMapUrl,
'battle-map-sixty-fourth': sixtyFourthBattleMapUrl,
'battle-map-sixty-fifth': sixtyFifthBattleMapUrl,
+ 'battle-map-sixty-sixth': sixtySixthBattleMapUrl,
'battle-map-fifty-seventh': fiftySeventhBattleMapUrl,
'battle-map-fifty-sixth': fiftySixthBattleMapUrl
};
diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts
index 5632c59..dabcb5b 100644
--- a/src/game/data/battles.ts
+++ b/src/game/data/battles.ts
@@ -59,6 +59,10 @@ import {
sixtyFifthBattleMap,
sixtyFifthBattleUnits,
sixtyFifthBattleVictoryPages,
+ sixtySixthBattleBonds,
+ sixtySixthBattleMap,
+ sixtySixthBattleUnits,
+ sixtySixthBattleVictoryPages,
fiftySeventhBattleBonds,
fiftySeventhBattleMap,
fiftySeventhBattleUnits,
@@ -330,7 +334,8 @@ export type BattleScenarioId =
| 'sixty-second-battle-qishan-renewed-offensive'
| 'sixty-third-battle-lucheng-pursuit'
| 'sixty-fourth-battle-weishui-camps'
- | 'sixty-fifth-battle-weishui-northbank';
+ | 'sixty-fifth-battle-weishui-northbank'
+ | 'sixty-sixth-battle-wuzhang-final';
export type BattleObjectiveKind = 'defeat-leader' | 'pacify-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory';
@@ -4407,6 +4412,73 @@ export const sixtyFifthBattleScenario: BattleScenarioDefinition = {
nextCampScene: 'CampScene'
};
+export const sixtySixthBattleScenario: BattleScenarioDefinition = {
+ id: 'sixty-sixth-battle-wuzhang-final',
+ title: '오장원 최종전',
+ victoryConditionLabel: '오장원 본영 방어와 장부 계승 확보',
+ defeatConditionLabel: '제갈량 퇴각 또는 귀환 장부 붕괴',
+ openingObjectiveLines: [
+ '위수 북안의 압박을 지나 오장원 본영까지 돌아왔습니다. 이번 싸움은 더 멀리 나아가는 전투가 아니라, 촉한군이 살아서 돌아가고 다음 세대가 장부를 이어 받을 길을 지키는 최종전입니다.',
+ '강유는 제갈량의 작전도를 이어 받고, 왕평과 마대는 퇴로와 측면을 나누어 맡았습니다. 황권과 이엄은 군량 장부를 보전하며 본영의 질서를 붙들어야 합니다.',
+ '사마의의 본대, 장합의 추격 기병, 곽회의 봉화 차단대를 동시에 누르십시오. 오장원 본영과 주변 마을을 지켜 내면 북벌의 뜻은 패배가 아니라 계승으로 남습니다.'
+ ],
+ map: sixtySixthBattleMap,
+ units: sixtySixthBattleUnits,
+ bonds: sixtySixthBattleBonds,
+ mapTextureKey: 'battle-map-sixty-sixth',
+ leaderUnitId: 'northern-twelfth-leader-sima-yi',
+ quickVictoryTurnLimit: 132,
+ baseVictoryGold: 22400,
+ objectives: [
+ {
+ id: 'hold-wuzhang-main-camp',
+ kind: 'defeat-leader',
+ label: '사마의 오장원 본대 격파',
+ rewardGold: 16000,
+ unitId: 'northern-twelfth-leader-sima-yi'
+ },
+ {
+ id: 'stop-zhang-he-final-pursuit',
+ kind: 'defeat-leader',
+ label: '장합 최종 추격대 저지',
+ rewardGold: 9800,
+ unitId: 'northern-twelfth-officer-zhang-he'
+ },
+ {
+ id: 'silence-guo-huai-wuzhang-beacons',
+ kind: 'defeat-leader',
+ label: '곽회 봉화 차단',
+ rewardGold: 9100,
+ unitId: 'northern-twelfth-officer-guo-huai'
+ },
+ {
+ id: 'preserve-wuzhang-camps',
+ kind: 'secure-terrain',
+ label: '오장원 본영 확보',
+ rewardGold: 7600,
+ terrain: 'camp'
+ },
+ {
+ id: 'protect-returning-villages',
+ kind: 'secure-terrain',
+ label: '귀환로 마을 보호',
+ rewardGold: 7200,
+ terrain: 'village'
+ },
+ {
+ id: 'quick',
+ kind: 'quick-victory',
+ label: '132턴 이내 오장원 수습',
+ rewardGold: 5000,
+ maxTurn: 132
+ }
+ ],
+ defeatConditions: [{ kind: 'unit-defeated', unitId: 'zhuge-liang' }],
+ itemRewards: ['금 150', '상처약 114', '군주 56', '오장원 귀환 장부 1', '강유 계승 지도 1'],
+ victoryPages: sixtySixthBattleVictoryPages,
+ nextCampScene: 'CampScene'
+};
+
export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id;
export const battleScenarios: Record = {
@@ -4474,7 +4546,8 @@ export const battleScenarios: Record
'sixty-second-battle-qishan-renewed-offensive': sixtySecondBattleScenario,
'sixty-third-battle-lucheng-pursuit': sixtyThirdBattleScenario,
'sixty-fourth-battle-weishui-camps': sixtyFourthBattleScenario,
- 'sixty-fifth-battle-weishui-northbank': sixtyFifthBattleScenario
+ 'sixty-fifth-battle-weishui-northbank': sixtyFifthBattleScenario,
+ 'sixty-sixth-battle-wuzhang-final': sixtySixthBattleScenario
};
export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId];
diff --git a/src/game/data/campaignFlow.ts b/src/game/data/campaignFlow.ts
index 002e8ce..206dc34 100644
--- a/src/game/data/campaignFlow.ts
+++ b/src/game/data/campaignFlow.ts
@@ -20,6 +20,7 @@ import {
sixtyThirdBattleScenario,
sixtyFourthBattleScenario,
sixtyFifthBattleScenario,
+ sixtySixthBattleScenario,
fortiethBattleScenario,
fortyFirstBattleScenario,
fortySecondBattleScenario,
@@ -94,6 +95,8 @@ import {
sixtyFourthBattleVictoryPages,
sixtyFifthBattleIntroPages,
sixtyFifthBattleVictoryPages,
+ sixtySixthBattleIntroPages,
+ sixtySixthBattleVictoryPages,
fiftyFirstBattleIntroPages,
fiftyFirstBattleVictoryPages,
fiftySecondBattleIntroPages,
@@ -952,6 +955,17 @@ const sortieFlows: Record = {
campaignStep: 'sixty-fifth-battle',
pages: [...sixtyFourthBattleVictoryPages, ...sixtyFifthBattleIntroPages]
},
+ 'sixty-fifth-camp': {
+ afterBattleId: sixtyFifthBattleScenario.id,
+ eyebrow: '다음 전장',
+ title: sixtySixthBattleScenario.title,
+ description:
+ '위수 북안 압박 후 촉한군은 오장원 본영으로 돌아와 마지막 장부를 정리합니다. 이번 출전은 전공을 더하는 전투가 아니라, 제갈량의 명령과 강유의 계승, 병사들의 귀환로를 함께 지켜 내는 최종장입니다.',
+ rewardHint: `예상 보상: ${sixtySixthBattleScenario.title} 개방 / 오장원 귀환 장부`,
+ nextBattleId: sixtySixthBattleScenario.id,
+ campaignStep: 'sixty-sixth-battle',
+ pages: [...sixtyFifthBattleVictoryPages, ...sixtySixthBattleIntroPages]
+ },
[sixtyFifthBattleScenario.id]: {
afterBattleId: sixtyFifthBattleScenario.id,
eyebrow: '최종 후일담',
@@ -963,6 +977,17 @@ const sortieFlows: Record = {
pages: [...sixtyFifthBattleVictoryPages, ...endingEpiloguePages],
unavailableNotice: '최종 후일담과 엔딩을 이미 확인했습니다. 타이틀에서 이어하기를 선택하면 엔딩 화면으로 돌아갑니다.'
},
+ [sixtySixthBattleScenario.id]: {
+ afterBattleId: sixtySixthBattleScenario.id,
+ eyebrow: '최종 에필로그',
+ title: '북벌의 끝과 남은 뜻',
+ description:
+ '오장원 본영을 지켜 낸 뒤 촉한군은 북벌의 마지막 장부를 닫습니다. 강유는 이어 받은 지도를 품고, 남은 장수들은 병사들이 돌아갈 길을 정리합니다.',
+ rewardHint: '최종 보상: 오장원 에필로그 / 엔딩 개방',
+ campaignStep: 'ending-complete',
+ pages: [...sixtySixthBattleVictoryPages, ...endingEpiloguePages],
+ unavailableNotice: '최종 에필로그와 엔딩을 이미 확인했습니다. 타이틀에서 이어하기를 선택하면 엔딩 화면으로 돌아갑니다.'
+ },
'northern-campaign-prep-camp-legacy': {
afterBattleId: fiftyFourthBattleScenario.id,
eyebrow: '다음 전장 준비',
diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts
index 57f36f7..6e83152 100644
--- a/src/game/data/scenario.ts
+++ b/src/game/data/scenario.ts
@@ -4119,6 +4119,59 @@ export const sixtyFifthBattleVictoryPages: StoryPage[] = [
}
];
+export const sixtySixthBattleIntroPages: StoryPage[] = [
+ {
+ id: 'sixty-sixth-wuzhang-council',
+ bgm: 'story-dark',
+ chapter: '오장원의 밤',
+ background: 'story-weishui-northbank',
+ speaker: '제갈량',
+ text: '위수 북안의 장부를 정리하자 오장원의 등불이 마지막 작전도를 비추었습니다. 더 멀리 밀어붙이는 전공보다, 병사들이 살아 돌아갈 길과 다음 세대가 이어 받을 질서가 먼저였습니다.'
+ },
+ {
+ id: 'sixty-sixth-inherited-ledger',
+ bgm: 'battle-prep',
+ chapter: '이어 받을 장부',
+ background: 'story-northern',
+ speaker: '강유',
+ text: '강유는 새로 고친 진군 장부를 품에 넣었습니다. 오늘의 북벌은 끝을 향하지만 촉한의 뜻은 여기서 끊기지 않습니다. 그는 이 싸움이 승리보다 더 무거운 계승의 시험임을 알았습니다.'
+ },
+ {
+ id: 'sixty-sixth-final-orders',
+ bgm: 'story-dark',
+ chapter: '마지막 명령',
+ background: 'story-hanzhong-rain',
+ speaker: '왕평',
+ text: '왕평은 퇴로의 북을 낮게 울리고, 마대와 위연은 측면의 기병을 다시 묶었습니다. 사마의가 마지막으로 압박해 오기 전에 본영과 장부, 병사들의 귀로를 함께 지켜야 합니다.'
+ }
+];
+
+export const sixtySixthBattleVictoryPages: StoryPage[] = [
+ {
+ id: 'sixty-sixth-victory-wuzhang-held',
+ bgm: 'militia-theme',
+ chapter: '지켜 낸 오장원',
+ background: 'story-weishui-northbank',
+ text: '촉한군은 오장원 본영을 끝까지 지켜 냈습니다. 위군의 추격 깃발은 강가에서 멈추었고, 제갈량의 장부는 불타지 않은 채 다음 손으로 건너갔습니다.'
+ },
+ {
+ id: 'sixty-sixth-victory-jiang-wei-oath',
+ bgm: 'battle-prep',
+ chapter: '새 길의 맹세',
+ background: 'story-northern',
+ speaker: '강유',
+ text: '강유는 퇴각하는 병사들의 뒤에서 마지막으로 북쪽을 바라보았습니다. 오늘은 물러나지만, 백성의 이름으로 세운 나라가 스스로를 잊지 않는 한 북벌의 뜻도 사라지지 않을 것입니다.'
+ },
+ {
+ id: 'sixty-sixth-victory-returning-banners',
+ bgm: 'story-dark',
+ chapter: '돌아가는 깃발',
+ background: 'story-hanzhong-rain',
+ speaker: '내레이션',
+ text: '오장원의 불빛은 하나씩 꺼졌습니다. 하지만 도원에서 시작된 맹세, 형주와 익주를 지나 촉한에 이른 사람들의 이름은 마지막 행군 속에서도 서로를 붙들었습니다.'
+ }
+];
+
export const endingEpiloguePages: StoryPage[] = [
{
id: 'ending-wuzhang-council',
@@ -4689,6 +4742,12 @@ export const sixtyFifthBattleMap: BattleMap = {
terrain: createSixtyFifthBattleTerrain()
};
+export const sixtySixthBattleMap: BattleMap = {
+ width: 132,
+ height: 114,
+ terrain: createSixtySixthBattleTerrain()
+};
+
export const firstBattleUnits: UnitData[] = [
{
id: 'liu-bei',
@@ -18379,6 +18438,102 @@ export const sixtyFifthBattleUnits: UnitData[] = [
...sixtyFifthEnemySpecs.map((spec) => createThirtyFirstEnemyUnit(...spec))
];
+const sixtySixthBattleAllyPositions: Record = {
+ 'guan-yu': { x: 15, y: 104 },
+ 'zhang-fei': { x: 24, y: 106 },
+ 'jian-yong': { x: 8, y: 110 },
+ 'mi-zhu': { x: 14, y: 111 },
+ 'sun-qian': { x: 20, y: 111 },
+ 'zhao-yun': { x: 31, y: 100 },
+ 'zhuge-liang': { x: 20, y: 98 },
+ 'ma-liang': { x: 27, y: 95 },
+ 'yi-ji': { x: 9, y: 112 },
+ 'gong-zhi': { x: 31, y: 109 },
+ 'huang-zhong': { x: 39, y: 98 },
+ 'wei-yan': { x: 47, y: 96 },
+ 'pang-tong': { x: 17, y: 94 },
+ 'fa-zheng': { x: 30, y: 92 },
+ 'wu-yi': { x: 50, y: 107 },
+ 'yan-yan': { x: 54, y: 100 },
+ 'li-yan': { x: 36, y: 92 },
+ 'huang-quan': { x: 14, y: 97 },
+ 'ma-chao': { x: 57, y: 96 },
+ 'ma-dai': { x: 63, y: 103 },
+ 'wang-ping': { x: 43, y: 92 },
+ 'jiang-wei': { x: 37, y: 95 }
+};
+
+const sixtySixthEnemySpecs: ReadonlyArray<
+ readonly [string, string, string, UnitClassKey, number, number, number, number, number]
+> = [
+ ['northern-twelfth-scout-a', '오장원 서측 척후병', '서측 언덕 정찰', 'bandit', 247, 1200, 320, 54, 91],
+ ['northern-twelfth-scout-b', '오장원 중앙 척후병', '중앙 진입 정찰', 'bandit', 247, 1200, 320, 68, 86],
+ ['northern-twelfth-scout-c', '오장원 동측 척후병', '동측 봉화 정찰', 'bandit', 248, 1212, 322, 84, 78],
+ ['northern-twelfth-scout-d', '위수 북로 척후병', '북로 차단 정찰', 'bandit', 248, 1212, 322, 100, 66],
+ ['northern-twelfth-scout-e', '사마의 전초 척후병', '본영 전초 정찰', 'bandit', 249, 1224, 324, 116, 51],
+ ['northern-twelfth-scout-f', '장합 추격 척후병', '추격로 정찰', 'bandit', 249, 1224, 324, 126, 36],
+ ['northern-twelfth-infantry-a', '위군 서측 보병', '서측 압박 보병', 'yellowTurban', 248, 1840, 434, 58, 88],
+ ['northern-twelfth-infantry-b', '위군 중앙 보병', '중앙 진입 보병', 'yellowTurban', 248, 1840, 434, 72, 82],
+ ['northern-twelfth-infantry-c', '위군 둔전 보병', '둔전 차단 보병', 'yellowTurban', 249, 1860, 438, 88, 74],
+ ['northern-twelfth-infantry-d', '위군 봉화 보병', '봉화대 보병', 'yellowTurban', 249, 1860, 438, 104, 61],
+ ['northern-twelfth-infantry-e', '위군 본영 보병', '본영 전면 보병', 'yellowTurban', 250, 1880, 442, 118, 47],
+ ['northern-twelfth-infantry-f', '위군 북안 보병', '북안 압박 보병', 'yellowTurban', 250, 1880, 442, 126, 30],
+ ['northern-twelfth-infantry-g', '위군 회랑 보병', '퇴로 회랑 보병', 'yellowTurban', 251, 1900, 446, 96, 92],
+ ['northern-twelfth-infantry-h', '위군 산문 보병', '산문 봉쇄 보병', 'yellowTurban', 251, 1900, 446, 128, 20],
+ ['northern-twelfth-archer-a', '오장원 서측 궁병', '서측 능선 궁병', 'archer', 248, 1340, 356, 66, 81],
+ ['northern-twelfth-archer-b', '오장원 중앙 궁병', '중앙 사격 궁병', 'archer', 248, 1340, 356, 82, 74],
+ ['northern-twelfth-archer-c', '둔전 차단 궁병', '둔전 사격 궁병', 'archer', 249, 1352, 358, 98, 64],
+ ['northern-twelfth-archer-d', '봉화대 궁병', '봉화 엄호 궁병', 'archer', 250, 1364, 360, 112, 52],
+ ['northern-twelfth-archer-e', '본영 압박 궁병', '본영 사격 궁병', 'archer', 250, 1364, 360, 124, 38],
+ ['northern-twelfth-archer-f', '북안 잔류 궁병', '북안 차단 궁병', 'archer', 251, 1376, 362, 130, 22],
+ ['northern-twelfth-cavalry-a', '장합 추격 기병', '서측 추격 기병', 'cavalry', 249, 1940, 460, 54, 78],
+ ['northern-twelfth-cavalry-b', '장합 추격 기병', '중앙 추격 기병', 'cavalry', 249, 1940, 460, 70, 73],
+ ['northern-twelfth-cavalry-c', '위군 돌파 기병', '둔전 돌파 기병', 'cavalry', 250, 1962, 464, 86, 67],
+ ['northern-twelfth-cavalry-d', '곽회 차단 기병', '봉화 차단 기병', 'cavalry', 251, 1984, 468, 104, 56],
+ ['northern-twelfth-cavalry-e', '사마의 예비 기병', '본영 예비 기병', 'cavalry', 251, 1984, 468, 120, 44],
+ ['northern-twelfth-cavalry-f', '북안 기습 기병', '북안 차단 기병', 'cavalry', 252, 2006, 472, 130, 30],
+ ['northern-twelfth-camp-guard-a', '오장원 서측 수비대', '서측 진문 수비', 'yellowTurban', 249, 1860, 438, 46, 69],
+ ['northern-twelfth-camp-guard-b', '오장원 둔전 수비대', '둔전 수비', 'yellowTurban', 249, 1860, 438, 64, 64],
+ ['northern-twelfth-camp-guard-c', '중앙 장부 수비대', '중앙 장부 수비', 'yellowTurban', 250, 1880, 442, 84, 56],
+ ['northern-twelfth-camp-guard-d', '봉화 길 수비대', '봉화 길 수비', 'yellowTurban', 250, 1880, 442, 102, 46],
+ ['northern-twelfth-camp-guard-e', '사마의 본영 수비대', '본영 수비', 'yellowTurban', 251, 1900, 446, 118, 35],
+ ['northern-twelfth-camp-guard-f', '북안 후위 수비대', '후위 수비', 'yellowTurban', 251, 1900, 446, 130, 19],
+ ['northern-twelfth-strategist-a', '위군 장부 관리', '둔전 장부 관리', 'strategist', 249, 1420, 374, 84, 62],
+ ['northern-twelfth-strategist-b', '위군 보급 관리', '퇴로 차단 관리', 'strategist', 250, 1432, 376, 100, 50],
+ ['northern-twelfth-strategist-c', '사마의 지시 관리', '본영 명령 관리', 'strategist', 250, 1432, 376, 116, 40],
+ ['northern-twelfth-strategist-d', '곽회 봉화 관리', '봉화 지휘 관리', 'strategist', 251, 1444, 378, 126, 64],
+ ['northern-twelfth-officer-wuzhang', '오장원 전진장', '전진 진영 무장', 'rebelLeader', 252, 2000, 472, 90, 84],
+ ['northern-twelfth-officer-ledger', '장부 차단장', '장부 차단 무장', 'rebelLeader', 252, 2000, 472, 111, 74],
+ ['northern-twelfth-officer-guo-huai', '곽회', '봉화 차단 무장', 'rebelLeader', 252, 1980, 468, 122, 70],
+ ['northern-twelfth-officer-zhang-he', '장합', '추격 총대장', 'rebelLeader', 253, 2040, 478, 127, 34],
+ ['northern-twelfth-leader-sima-yi', '사마의', '오장원 총지휘관', 'rebelLeader', 254, 2100, 486, 124, 46]
+];
+
+export const sixtySixthBattleUnits: UnitData[] = [
+ ...[
+ ...firstBattleUnits.filter((unit) => unit.faction === 'ally' && unit.id !== 'liu-bei'),
+ ...xuzhouRecruitUnits,
+ ...caoBreakRecruitUnits,
+ ...liuBiaoRecruitUnits,
+ ...zhugeRecruitUnits,
+ ...jingzhouRecruitUnits,
+ ...guiyangRecruitUnits,
+ ...wulingRecruitUnits,
+ ...changshaRecruitUnits,
+ ...yizhouRecruitUnits,
+ ...fuPassRecruitUnits,
+ ...luoCastleRecruitUnits,
+ ...luoCastleProperRecruitUnits,
+ ...chengduPressureRecruitUnits,
+ ...chengduSurrenderRecruitUnits,
+ ...hanzhongOpeningRecruitUnits,
+ ...hanzhongScoutRecruitUnits,
+ ...hanzhongMainRecruitUnits,
+ ...jiangWeiRecruitUnits
+ ].map((unit) => placeScenarioUnit(unit, sixtySixthBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })),
+ ...sixtySixthEnemySpecs.map((spec) => createThirtyFirstEnemyUnit(...spec))
+];
+
export const firstBattleBonds: BattleBond[] = [
{
id: 'liu-bei__guan-yu',
@@ -19896,6 +20051,46 @@ export const sixtyFifthBattleBonds: BattleBond[] = [
...northernEleventhBattleBonds
].map(cloneBattleBondForScenario);
+export const northernTwelfthBattleBonds: BattleBond[] = [
+ {
+ id: 'zhuge-liang__jiang-wei_northern-twelfth',
+ unitIds: ['zhuge-liang', 'jiang-wei'],
+ title: '계승의 장부',
+ level: 64,
+ exp: 0,
+ description: '제갈량의 본영 명령과 강유의 전진 판단이 맞물리면, 오장원에서 퇴각과 반격의 균형을 잃지 않습니다.'
+ },
+ {
+ id: 'wang-ping__ma-dai_northern-twelfth',
+ unitIds: ['wang-ping', 'ma-dai'],
+ title: '퇴로와 측면',
+ level: 60,
+ exp: 0,
+ description: '왕평이 퇴로를 붙들고 마대가 측면 기병을 끊으면, 장합의 마지막 추격을 본영 밖에서 묶어 둘 수 있습니다.'
+ },
+ {
+ id: 'huang-quan__li-yan_northern-twelfth',
+ unitIds: ['huang-quan', 'li-yan'],
+ title: '군량과 귀환 장부',
+ level: 58,
+ exp: 0,
+ description: '황권의 전장 기록과 이엄의 보급 장부가 함께 움직이면, 긴 행군 끝에도 병사들이 돌아갈 순서를 잃지 않습니다.'
+ },
+ {
+ id: 'wei-yan__jiang-wei_northern-twelfth',
+ unitIds: ['wei-yan', 'jiang-wei'],
+ title: '앞길을 보는 두 검',
+ level: 56,
+ exp: 0,
+ description: '위연의 돌파력과 강유의 신중한 후위 판단이 함께 서면, 사마의의 지연선을 흔들면서도 본영을 비우지 않습니다.'
+ }
+];
+
+export const sixtySixthBattleBonds: BattleBond[] = [
+ ...sixtyFifthBattleBonds,
+ ...northernTwelfthBattleBonds
+].map(cloneBattleBondForScenario);
+
function createEighthBattleTerrain(): TerrainType[][] {
return Array.from({ length: 22 }, (_, y) =>
Array.from({ length: 26 }, (_, x): TerrainType => {
@@ -23617,6 +23812,87 @@ function createSixtyFifthBattleTerrain(): TerrainType[][] {
);
}
+function createSixtySixthBattleTerrain(): TerrainType[][] {
+ return Array.from({ length: 114 }, (_, y) =>
+ Array.from({ length: 132 }, (_, x): TerrainType => {
+ if (
+ (x >= 0 && x <= 131 && y >= 22 && y <= 32) ||
+ (x >= 4 && x <= 131 && y >= 38 && y <= 48)
+ ) {
+ return 'river';
+ }
+ if (
+ (x >= 4 && x <= 95 && y >= 99 && y <= 107) ||
+ (x >= 24 && x <= 114 && y >= 87 && y <= 97) ||
+ (x >= 49 && x <= 123 && y >= 75 && y <= 85) ||
+ (x >= 79 && x <= 130 && y >= 61 && y <= 73) ||
+ (x >= 101 && x <= 131 && y >= 49 && y <= 60) ||
+ (x >= 33 && x <= 45 && y >= 79 && y <= 111) ||
+ (x >= 61 && x <= 72 && y >= 64 && y <= 101) ||
+ (x >= 91 && x <= 102 && y >= 50 && y <= 90) ||
+ (x >= 105 && x <= 116 && y >= 31 && y <= 75)
+ ) {
+ return 'road';
+ }
+ if (
+ (x >= 0 && x <= 44 && y >= 70 && y <= 104) ||
+ (x >= 47 && x <= 90 && y >= 83 && y <= 105) ||
+ (x >= 96 && x <= 131 && y >= 76 && y <= 103) ||
+ (x >= 15 && x <= 48 && y >= 52 && y <= 73) ||
+ (x >= 105 && x <= 131 && y >= 61 && y <= 82) ||
+ (x >= 120 && x <= 131 && y >= 34 && y <= 57)
+ ) {
+ return 'forest';
+ }
+ if (
+ (x >= 0 && x <= 58 && y <= 20) ||
+ (x >= 48 && x <= 101 && y <= 24) ||
+ (x >= 92 && x <= 131 && y <= 31) ||
+ (x >= 103 && x <= 131 && y >= 54 && y <= 81)
+ ) {
+ return 'hill';
+ }
+ if (
+ (x <= 2 && y <= 106) ||
+ (x >= 129 && y <= 98) ||
+ (x >= 42 && x <= 49 && y >= 0 && y <= 75) ||
+ (x >= 83 && x <= 90 && y >= 0 && y <= 58) ||
+ (x >= 67 && x <= 76 && y >= 94 && y <= 113)
+ ) {
+ return 'cliff';
+ }
+ if (
+ (x >= 13 && x <= 36 && y >= 95 && y <= 107) ||
+ (x >= 46 && x <= 72 && y >= 81 && y <= 94) ||
+ (x >= 86 && x <= 110 && y >= 67 && y <= 80) ||
+ (x >= 108 && x <= 129 && y >= 51 && y <= 66) ||
+ (x >= 116 && x <= 131 && y >= 34 && y <= 46)
+ ) {
+ return 'village';
+ }
+ if (
+ (x >= 22 && x <= 55 && y >= 88 && y <= 103) ||
+ (x >= 58 && x <= 90 && y >= 75 && y <= 90) ||
+ (x >= 94 && x <= 123 && y >= 61 && y <= 76) ||
+ (x >= 103 && x <= 126 && y >= 32 && y <= 49)
+ ) {
+ return 'camp';
+ }
+ if (
+ (x >= 52 && x <= 77 && y >= 55 && y <= 72) ||
+ (x >= 77 && x <= 108 && y >= 44 && y <= 61) ||
+ (x >= 99 && x <= 128 && y >= 53 && y <= 71) ||
+ (x >= 108 && x <= 131 && y >= 18 && y <= 44) ||
+ (x >= 114 && x <= 131 && y >= 73 && y <= 92) ||
+ (x >= 119 && x <= 131 && y >= 5 && y <= 19)
+ ) {
+ return 'fort';
+ }
+ return 'plain';
+ })
+ );
+}
+
function placeScenarioUnit(unit: UnitData, position: { x: number; y: number }): UnitData {
return {
...cloneUnitForScenario(unit),
diff --git a/src/game/data/unitAssets.ts b/src/game/data/unitAssets.ts
new file mode 100644
index 0000000..431294b
--- /dev/null
+++ b/src/game/data/unitAssets.ts
@@ -0,0 +1,127 @@
+import Phaser from 'phaser';
+
+export type UnitDirection = 'south' | 'east' | 'north' | 'west';
+
+const unitWalkFrameCount = 4;
+export const unitSheetFrameSize = 313;
+const unitWalkFrameRate = 8;
+const unitIdleFrameRate = 4;
+
+const unitSheetRows: Record = {
+ south: 0,
+ east: 1,
+ north: 2,
+ west: 3
+};
+
+type UnitSheetAsset = {
+ key: string;
+ url: string;
+ actionKey: string;
+ actionUrl: string;
+};
+
+const unitSheetModules = import.meta.glob('../../assets/images/units/unit-*.png', {
+ eager: true,
+ import: 'default'
+}) as Record;
+
+function textureKeyFromPath(path: string) {
+ const fileName = path.split('/').pop() ?? path;
+ return fileName.replace(/\.png$/i, '');
+}
+
+const unitSheetUrls = Object.fromEntries(
+ Object.entries(unitSheetModules).map(([path, url]) => [textureKeyFromPath(path), url])
+);
+
+const unitSheets = Object.entries(unitSheetUrls)
+ .filter(([key]) => !key.endsWith('-actions'))
+ .map(([key, url]) => ({
+ key,
+ url,
+ actionKey: `${key}-actions`,
+ actionUrl: unitSheetUrls[`${key}-actions`]
+ }))
+ .filter((sheet): sheet is UnitSheetAsset => Boolean(sheet.actionUrl))
+ .sort((left, right) => left.key.localeCompare(right.key));
+
+const unitSheetAssetsByKey = new Map(unitSheets.map((sheet) => [sheet.key, sheet]));
+
+export function unitTextureVariantKeys(baseKey: string) {
+ const prefix = `${baseKey}-`;
+ const keys = unitSheets
+ .map((sheet) => sheet.key)
+ .filter((key) => key === baseKey || key.startsWith(prefix))
+ .sort();
+
+ return keys.length > 0 ? keys : [baseKey];
+}
+
+export function loadUnitSheets(scene: Phaser.Scene, keys: Iterable, onReady: () => void) {
+ const uniqueKeys = Array.from(new Set(keys));
+ const missingSheets = uniqueKeys
+ .filter((key) => !scene.textures.exists(key) || !scene.textures.exists(`${key}-actions`))
+ .map((key) => unitSheetAssetsByKey.get(key) ?? missingUnitSheet(key));
+
+ if (missingSheets.length <= 0) {
+ onReady();
+ return;
+ }
+
+ scene.load.once('complete', onReady);
+ missingSheets.forEach(({ key, url, actionKey, actionUrl }) => {
+ if (!scene.textures.exists(key)) {
+ scene.load.spritesheet(key, url, {
+ frameWidth: unitSheetFrameSize,
+ frameHeight: unitSheetFrameSize
+ });
+ }
+ if (!scene.textures.exists(actionKey)) {
+ scene.load.spritesheet(actionKey, actionUrl, {
+ frameWidth: unitSheetFrameSize,
+ frameHeight: unitSheetFrameSize
+ });
+ }
+ });
+ scene.load.start();
+}
+
+export function ensureUnitAnimations(scene: Phaser.Scene, keys: Iterable) {
+ Array.from(new Set(keys)).forEach((key) => {
+ if (!scene.textures.exists(key)) {
+ return;
+ }
+
+ (['south', 'east', 'north', 'west'] as UnitDirection[]).forEach((direction) => {
+ const frames = Array.from({ length: unitWalkFrameCount }, (_, frameIndex) => ({
+ key,
+ frame: unitSheetRows[direction] * unitWalkFrameCount + frameIndex
+ }));
+ const walkAnimationKey = `${key}-walk-${direction}`;
+ const idleAnimationKey = `${key}-idle-${direction}`;
+
+ if (!scene.anims.exists(walkAnimationKey)) {
+ scene.anims.create({
+ key: walkAnimationKey,
+ frames,
+ frameRate: unitWalkFrameRate,
+ repeat: -1
+ });
+ }
+
+ if (!scene.anims.exists(idleAnimationKey)) {
+ scene.anims.create({
+ key: idleAnimationKey,
+ frames,
+ frameRate: unitIdleFrameRate,
+ repeat: -1
+ });
+ }
+ });
+ });
+}
+
+function missingUnitSheet(key: string): never {
+ throw new Error(`Missing unit sheet asset for ${key}`);
+}
diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts
index f397455..b965e2b 100644
--- a/src/game/scenes/BattleScene.ts
+++ b/src/game/scenes/BattleScene.ts
@@ -4,6 +4,7 @@ import { battleMapAssets } from '../data/battleMapAssets';
import { type BattleBond, type UnitData, type UnitStats } from '../data/scenario';
import { defaultBattleScenario, getBattleScenario, type BattleObjectiveDefinition, type BattleScenarioDefinition } from '../data/battles';
import { getTerrainRule, getUnitClass, type TerrainType, type UnitClassKey } from '../data/battleRules';
+import { ensureUnitAnimations, loadUnitSheets, unitTextureVariantKeys as unitAssetTextureVariantKeys } from '../data/unitAssets';
import {
equipmentExpToNext,
equipmentSlotLabels,
@@ -2846,7 +2847,7 @@ export class BattleScene extends Phaser.Scene {
this.installDebugHotkeys();
this.add.rectangle(0, 0, width, height, 0x080b0d).setOrigin(0);
- this.ensureScenarioMapTexture(() => this.drawBattleScene());
+ this.ensureScenarioAssets(() => this.drawBattleScene());
}
private drawBattleScene() {
@@ -2858,6 +2859,10 @@ export class BattleScene extends Phaser.Scene {
this.renderRosterPanel('ally', '행동할 장수를 선택하세요.');
}
+ private ensureScenarioAssets(onReady: () => void) {
+ this.ensureScenarioMapTexture(() => this.ensureScenarioUnitTextures(onReady));
+ }
+
private ensureScenarioMapTexture(onReady: () => void) {
const textureKey = battleScenario.mapTextureKey;
if (this.textures.exists(textureKey)) {
@@ -2891,6 +2896,39 @@ export class BattleScene extends Phaser.Scene {
this.load.start();
}
+ private ensureScenarioUnitTextures(onReady: () => void) {
+ const textureKeys = Array.from(new Set(battleUnits.map((unit) => this.unitTextureKey(unit))));
+ const texturesReady = textureKeys.every((key) => this.textures.exists(key) && this.textures.exists(`${key}-actions`));
+
+ if (texturesReady) {
+ ensureUnitAnimations(this, textureKeys);
+ onReady();
+ return;
+ }
+
+ const loadingText = this.add.text(this.scale.width / 2, this.scale.height / 2 + 34, '부대 깃발을 정렬하는 중...', {
+ fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
+ fontSize: '22px',
+ color: '#f5e6b8',
+ stroke: '#05070a',
+ strokeThickness: 4
+ });
+ loadingText.setOrigin(0.5);
+ loadingText.setDepth(90);
+
+ const handleLoadError = () => {
+ loadingText.setText('부대 화상을 불러오지 못했습니다.');
+ };
+ this.load.once('loaderror', handleLoadError);
+
+ loadUnitSheets(this, textureKeys, () => {
+ this.load.off('loaderror', handleLoadError);
+ loadingText.destroy();
+ ensureUnitAnimations(this, textureKeys);
+ onReady();
+ });
+ }
+
private resetBattleData(campaign?: CampaignState) {
const selected = new Set(campaign?.selectedSortieUnitIds ?? []);
const forcedAllyIds = new Set(
@@ -8372,16 +8410,7 @@ export class BattleScene extends Phaser.Scene {
}
private unitTextureVariantKeys(baseKey: string) {
- const prefix = `${baseKey}-`;
- return this.textures
- .getTextureKeys()
- .filter((key) => {
- if (key === baseKey) {
- return true;
- }
- return key.startsWith(prefix) && !key.endsWith('-actions') && this.textures.exists(`${key}-actions`);
- })
- .sort();
+ return unitAssetTextureVariantKeys(baseKey);
}
private hashString(value: string) {
diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts
index 03d96f9..a208139 100644
--- a/src/game/scenes/BootScene.ts
+++ b/src/game/scenes/BootScene.ts
@@ -3,53 +3,6 @@ import titleBackgroundUrl from '../../assets/images/taoyuan-oath-title.png';
import { portraitAssets } from '../data/portraitAssets';
import { storyBackgroundAssets } from '../data/storyAssets';
-type UnitDirection = 'south' | 'east' | 'north' | 'west';
-
-const unitWalkFrameCount = 4;
-const unitSheetFrameSize = 313;
-const unitWalkFrameRate = 8;
-const unitIdleFrameRate = 4;
-const unitSheetRows: Record = {
- south: 0,
- east: 1,
- north: 2,
- west: 3
-};
-
-type UnitSheetAsset = {
- key: string;
- url: string;
- actionKey: string;
- actionUrl: string;
-};
-
-const unitSheetModules = import.meta.glob('../../assets/images/units/unit-*.png', {
- eager: true,
- import: 'default'
-}) as Record;
-
-function textureKeyFromPath(path: string) {
- const fileName = path.split('/').pop() ?? path;
- return fileName.replace(/\.png$/i, '');
-}
-
-const unitSheetUrls = Object.fromEntries(
- Object.entries(unitSheetModules).map(([path, url]) => [textureKeyFromPath(path), url])
-);
-
-const sourceUnitSheets: UnitSheetAsset[] = Object.entries(unitSheetUrls)
- .filter(([key]) => !key.endsWith('-actions'))
- .map(([key, url]) => ({
- key,
- url,
- actionKey: `${key}-actions`,
- actionUrl: unitSheetUrls[`${key}-actions`]
- }))
- .filter((sheet): sheet is UnitSheetAsset => Boolean(sheet.actionUrl))
- .sort((left, right) => left.key.localeCompare(right.key));
-
-const animatedUnitSheets = sourceUnitSheets.map(({ key }) => ({ key }));
-
export class BootScene extends Phaser.Scene {
constructor() {
super('BootScene');
@@ -59,23 +12,11 @@ export class BootScene extends Phaser.Scene {
this.load.image('title-taoyuan', titleBackgroundUrl);
this.load.image('story-militia', storyBackgroundAssets['story-militia']);
Object.entries(portraitAssets).forEach(([key, url]) => this.load.image(`portrait-${key}`, url));
-
- sourceUnitSheets.forEach(({ key, url, actionKey, actionUrl }) => {
- this.load.spritesheet(key, url, {
- frameWidth: unitSheetFrameSize,
- frameHeight: unitSheetFrameSize
- });
- this.load.spritesheet(actionKey, actionUrl, {
- frameWidth: unitSheetFrameSize,
- frameHeight: unitSheetFrameSize
- });
- });
}
create() {
this.createPetalTexture();
this.createEquipmentIconTextures();
- this.createUnitAnimations();
this.scene.start('TitleScene');
}
@@ -339,34 +280,4 @@ export class BootScene extends Phaser.Scene {
graphics.strokePath();
}
- private createUnitAnimations() {
- animatedUnitSheets.forEach(({ key }) => {
- (['south', 'east', 'north', 'west'] as UnitDirection[]).forEach((direction) => {
- const frames = Array.from({ length: unitWalkFrameCount }, (_, frameIndex) => ({
- key,
- frame: unitSheetRows[direction] * unitWalkFrameCount + frameIndex
- }));
- const walkAnimationKey = `${key}-walk-${direction}`;
- const idleAnimationKey = `${key}-idle-${direction}`;
-
- if (!this.anims.exists(walkAnimationKey)) {
- this.anims.create({
- key: walkAnimationKey,
- frames,
- frameRate: unitWalkFrameRate,
- repeat: -1
- });
- }
-
- if (!this.anims.exists(idleAnimationKey)) {
- this.anims.create({
- key: idleAnimationKey,
- frames,
- frameRate: unitIdleFrameRate,
- repeat: -1
- });
- }
- });
- });
- }
}
diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts
index ef0a282..1a85224 100644
--- a/src/game/scenes/CampScene.ts
+++ b/src/game/scenes/CampScene.ts
@@ -62,6 +62,7 @@ import {
northernSixthBattleBonds,
northernTenthBattleBonds,
northernThirdBattleBonds,
+ northernTwelfthBattleBonds,
wulingRecruitBonds,
wulingRecruitUnits,
xuzhouRecruitBonds,
@@ -462,7 +463,8 @@ const campaignTimelineChapters: CampaignTimelineChapter[] = [
'sixty-second-battle-qishan-renewed-offensive',
'sixty-third-battle-lucheng-pursuit',
'sixty-fourth-battle-weishui-camps',
- 'sixty-fifth-battle-weishui-northbank'
+ 'sixty-fifth-battle-weishui-northbank',
+ 'sixty-sixth-battle-wuzhang-final'
],
nextHints: ['한중 창고 정비', '출전 무장 재편', '기산 출진로', '천수·가정 방면', '강유 합류', '진창 공성 재계산', '무도·음평 확보', '한중 우로 방어', '기산 재출정', '노성 추격', '위수 진영', '위수 북안']
}
@@ -533,7 +535,8 @@ const campBattleIds = {
sixtySecond: 'sixty-second-battle-qishan-renewed-offensive',
sixtyThird: 'sixty-third-battle-lucheng-pursuit',
sixtyFourth: 'sixty-fourth-battle-weishui-camps',
- sixtyFifth: 'sixty-fifth-battle-weishui-northbank'
+ sixtyFifth: 'sixty-fifth-battle-weishui-northbank',
+ sixtySixth: 'sixty-sixth-battle-wuzhang-final'
} as const;
const defaultRequiredSortieUnitIds = ['liu-bei'];
@@ -1368,6 +1371,23 @@ const sortieRulesByBattleId: Partial