From fe1f89760945a6e9719f16a4ffe4f18815784243 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Tue, 23 Jun 2026 15:46:28 +0900 Subject: [PATCH] Add Fan Castle vanguard sortie chapter --- docs/roadmap.md | 7 +- scripts/verify-flow.mjs | 236 ++++++++++++++--- .../images/battle/thirty-ninth-battle-map.svg | 89 +++++++ src/game/data/battles.ts | 73 +++++- src/game/data/campaignFlow.ts | 26 +- src/game/data/scenario.ts | 242 ++++++++++++++++++ src/game/scenes/BattleScene.ts | 64 ++++- src/game/scenes/BootScene.ts | 2 + src/game/scenes/CampScene.ts | 239 ++++++++++++++++- src/game/scenes/TitleScene.ts | 7 + src/game/state/campaignState.ts | 5 +- 11 files changed, 935 insertions(+), 55 deletions(-) create mode 100644 src/assets/images/battle/thirty-ninth-battle-map.svg diff --git a/docs/roadmap.md b/docs/roadmap.md index b5000f0..d837f60 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -62,16 +62,17 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of - Post-Hanzhong King of Hanzhong council flow, where the sortie button can advance a story-only political camp milestone and persist a dedicated campaign step before Shu-Han foundation - Shu-Han foundation story milestone, where the King of Hanzhong council advances into Liu Bei's formal state-building scene and points the next arc toward Guan Yu's Jing Province defense - Thirty-eighth battle Jing Province defense vanguard route, using a twenty-two-officer roster but limiting sortie to seven officers so Guan Yu's front, Ma Liang's local judgment, Mi Zhu's supply role, and Wu/Wei pressure become a deliberate pre-battle choice +- Thirty-ninth battle Fan Castle outer-front route, locking Liu Bei and Guan Yu as required sortie officers while the player chooses five support officers from the twenty-two-officer roster to pressure Pang De, Cao Ren, and Yu Jin - 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, recommended officers with reasons, class role, named equipment, core stats, bond partner, next-map terrain suitability, 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, 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 thirty-eighth battle victory, 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 camp states, campaign timeline state, and camp save state +- Flow verification script from title through the thirty-ninth battle victory, 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 camp states, campaign timeline state, and camp save state ## Next Steps -1. Build the next Fan Castle front setup, using Guan Yu's northward pressure and Wu/Wei diplomacy as the new campaign tension -2. Expand Jing Province defense events between battles, including patrol assignments, bond choices, and supply decisions that affect sortie recommendations +1. Build the Han River flood and Seven Armies setup, using Guan Yu's northward pressure, Yu Jin's main force, Pang De's resolve, and the water-control plan as the next campaign tension +2. Expand Fan Castle inter-battle events, including water-level scouting, siege logistics, bond choices, and supply decisions that affect sortie recommendations 3. Expand sortie selection into richer pre-battle officer planning, including explicit reserve drills, class practice, and bond-focused camp assignments 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 diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index c114c0f..b69f78e 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -1736,7 +1736,7 @@ try { if ( progressTabState?.activeTab !== 'progress' || progressTabState.campaignProgress?.completedKnown !== 17 || - progressTabState.campaignProgress?.totalKnown !== 38 || + progressTabState.campaignProgress?.totalKnown !== 39 || progressTabState.campaignProgress?.activeChapter?.title !== '적벽대전' || progressTabState.campaignProgress?.latestBattleTitle !== '융중 방문로' || progressTabState.campaignProgress?.nextBattleTitle !== '박망파 매복전' @@ -1888,7 +1888,7 @@ try { if ( postBowangProgressState?.activeTab !== 'progress' || postBowangProgressState.campaignProgress?.completedKnown !== 18 || - postBowangProgressState.campaignProgress?.totalKnown !== 38 || + postBowangProgressState.campaignProgress?.totalKnown !== 39 || postBowangProgressState.campaignProgress?.activeChapter?.title !== '적벽대전' || postBowangProgressState.campaignProgress?.latestBattleTitle !== '박망파 매복전' || postBowangProgressState.campaignProgress?.nextBattleTitle !== '장판파 피난로' @@ -2026,7 +2026,7 @@ try { if ( postChangbanProgressState?.activeTab !== 'progress' || postChangbanProgressState.campaignProgress?.completedKnown !== 19 || - postChangbanProgressState.campaignProgress?.totalKnown !== 38 || + postChangbanProgressState.campaignProgress?.totalKnown !== 39 || postChangbanProgressState.campaignProgress?.activeChapter?.title !== '적벽대전' || postChangbanProgressState.campaignProgress?.latestBattleTitle !== '장판파 피난로' || postChangbanProgressState.campaignProgress?.nextBattleTitle !== '강동 사절로' @@ -2164,7 +2164,7 @@ try { if ( postJiangdongProgressState?.activeTab !== 'progress' || postJiangdongProgressState.campaignProgress?.completedKnown !== 20 || - postJiangdongProgressState.campaignProgress?.totalKnown !== 38 || + postJiangdongProgressState.campaignProgress?.totalKnown !== 39 || postJiangdongProgressState.campaignProgress?.activeChapter?.title !== '적벽대전' || postJiangdongProgressState.campaignProgress?.latestBattleTitle !== '강동 사절로' || postJiangdongProgressState.campaignProgress?.nextBattleTitle !== '적벽 전초전' @@ -2304,7 +2304,7 @@ try { if ( postRedCliffsProgressState?.activeTab !== 'progress' || postRedCliffsProgressState.campaignProgress?.completedKnown !== 21 || - postRedCliffsProgressState.campaignProgress?.totalKnown !== 38 || + postRedCliffsProgressState.campaignProgress?.totalKnown !== 39 || postRedCliffsProgressState.campaignProgress?.activeChapter?.title !== '적벽대전' || postRedCliffsProgressState.campaignProgress?.latestBattleTitle !== '적벽 전초전' || postRedCliffsProgressState.campaignProgress?.nextBattleTitle !== '적벽 화공전' @@ -2444,7 +2444,7 @@ try { if ( postFireAttackProgressState?.activeTab !== 'progress' || postFireAttackProgressState.campaignProgress?.completedKnown !== 22 || - postFireAttackProgressState.campaignProgress?.totalKnown !== 38 || + postFireAttackProgressState.campaignProgress?.totalKnown !== 39 || postFireAttackProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' || postFireAttackProgressState.campaignProgress?.latestBattleTitle !== '적벽 화공전' || postFireAttackProgressState.campaignProgress?.nextBattleTitle !== '형주 남부 진입전' @@ -2587,7 +2587,7 @@ try { if ( postJingzhouProgressState?.activeTab !== 'progress' || postJingzhouProgressState.campaignProgress?.completedKnown !== 23 || - postJingzhouProgressState.campaignProgress?.totalKnown !== 38 || + postJingzhouProgressState.campaignProgress?.totalKnown !== 39 || postJingzhouProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' || postJingzhouProgressState.campaignProgress?.latestBattleTitle !== '형주 남부 진입전' || postJingzhouProgressState.campaignProgress?.nextBattleTitle !== '계양 설득전' @@ -2729,7 +2729,7 @@ try { if ( postGuiyangProgressState?.activeTab !== 'progress' || postGuiyangProgressState.campaignProgress?.completedKnown !== 24 || - postGuiyangProgressState.campaignProgress?.totalKnown !== 38 || + postGuiyangProgressState.campaignProgress?.totalKnown !== 39 || postGuiyangProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' || postGuiyangProgressState.campaignProgress?.latestBattleTitle !== '계양 설득전' || postGuiyangProgressState.campaignProgress?.nextBattleTitle !== '무릉 산길 확보전' @@ -2872,7 +2872,7 @@ try { if ( postWulingProgressState?.activeTab !== 'progress' || postWulingProgressState.campaignProgress?.completedKnown !== 25 || - postWulingProgressState.campaignProgress?.totalKnown !== 38 || + postWulingProgressState.campaignProgress?.totalKnown !== 39 || postWulingProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' || postWulingProgressState.campaignProgress?.latestBattleTitle !== '무릉 산길 확보전' || postWulingProgressState.campaignProgress?.nextBattleTitle !== '장사 노장 대면전' @@ -3021,7 +3021,7 @@ try { if ( postChangshaProgressState?.activeTab !== 'progress' || postChangshaProgressState.campaignProgress?.completedKnown !== 26 || - postChangshaProgressState.campaignProgress?.totalKnown !== 38 || + postChangshaProgressState.campaignProgress?.totalKnown !== 39 || postChangshaProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' || postChangshaProgressState.campaignProgress?.latestBattleTitle !== '장사 노장 대면전' || postChangshaProgressState.campaignProgress?.nextBattleTitle !== '익주 원군로' @@ -3166,7 +3166,7 @@ try { if ( postYizhouProgressState?.activeTab !== 'progress' || postYizhouProgressState.campaignProgress?.completedKnown !== 27 || - postYizhouProgressState.campaignProgress?.totalKnown !== 38 || + postYizhouProgressState.campaignProgress?.totalKnown !== 39 || postYizhouProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' || postYizhouProgressState.campaignProgress?.latestBattleTitle !== '익주 원군로' || postYizhouProgressState.campaignProgress?.nextBattleTitle !== '부수관 진입전' @@ -3313,7 +3313,7 @@ try { if ( postFuPassProgressState?.activeTab !== 'progress' || postFuPassProgressState.campaignProgress?.completedKnown !== 28 || - postFuPassProgressState.campaignProgress?.totalKnown !== 38 || + postFuPassProgressState.campaignProgress?.totalKnown !== 39 || postFuPassProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' || postFuPassProgressState.campaignProgress?.latestBattleTitle !== '부수관 진입전' || postFuPassProgressState.campaignProgress?.nextBattleTitle !== '낙성 외곽전' @@ -3462,7 +3462,7 @@ try { if ( postLuoProgressState?.activeTab !== 'progress' || postLuoProgressState.campaignProgress?.completedKnown !== 29 || - postLuoProgressState.campaignProgress?.totalKnown !== 38 || + postLuoProgressState.campaignProgress?.totalKnown !== 39 || postLuoProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' || postLuoProgressState.campaignProgress?.latestBattleTitle !== '낙성 외곽전' || postLuoProgressState.campaignProgress?.nextBattleTitle !== '낙봉파 매복전' @@ -3608,7 +3608,7 @@ try { if ( postLuofengProgressState?.activeTab !== 'progress' || postLuofengProgressState.campaignProgress?.completedKnown !== 30 || - postLuofengProgressState.campaignProgress?.totalKnown !== 38 || + postLuofengProgressState.campaignProgress?.totalKnown !== 39 || postLuofengProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' || postLuofengProgressState.campaignProgress?.latestBattleTitle !== '낙봉파 매복전' || postLuofengProgressState.campaignProgress?.nextBattleTitle !== '낙성 본성 공략' @@ -3760,7 +3760,7 @@ try { if ( postLuoMainProgressState?.activeTab !== 'progress' || postLuoMainProgressState.campaignProgress?.completedKnown !== 31 || - postLuoMainProgressState.campaignProgress?.totalKnown !== 38 || + postLuoMainProgressState.campaignProgress?.totalKnown !== 39 || postLuoMainProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' || postLuoMainProgressState.campaignProgress?.latestBattleTitle !== '낙성 본성 공략' || postLuoMainProgressState.campaignProgress?.nextBattleTitle !== '면죽관 압박전' @@ -3913,7 +3913,7 @@ try { if ( postMianzhuProgressState?.activeTab !== 'progress' || postMianzhuProgressState.campaignProgress?.completedKnown !== 32 || - postMianzhuProgressState.campaignProgress?.totalKnown !== 38 || + postMianzhuProgressState.campaignProgress?.totalKnown !== 39 || postMianzhuProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' || postMianzhuProgressState.campaignProgress?.latestBattleTitle !== '면죽관 압박전' || postMianzhuProgressState.campaignProgress?.nextBattleTitle !== '성도 항복 권고전' @@ -4067,7 +4067,7 @@ try { if ( postChengduProgressState?.activeTab !== 'progress' || postChengduProgressState.campaignProgress?.completedKnown !== 33 || - postChengduProgressState.campaignProgress?.totalKnown !== 38 || + postChengduProgressState.campaignProgress?.totalKnown !== 39 || postChengduProgressState.campaignProgress?.activeChapter?.title !== '촉한 건국' || postChengduProgressState.campaignProgress?.latestBattleTitle !== '성도 항복 권고전' || postChengduProgressState.campaignProgress?.nextBattleTitle !== '가맹관 마초 대면전' @@ -4222,7 +4222,7 @@ try { if ( postJiamengProgressState?.activeTab !== 'progress' || postJiamengProgressState.campaignProgress?.completedKnown !== 34 || - postJiamengProgressState.campaignProgress?.totalKnown !== 38 || + postJiamengProgressState.campaignProgress?.totalKnown !== 39 || postJiamengProgressState.campaignProgress?.activeChapter?.title !== '촉한 건국' || postJiamengProgressState.campaignProgress?.latestBattleTitle !== '가맹관 마초 대면전' || postJiamengProgressState.campaignProgress?.nextBattleTitle !== '양평관 정찰전' @@ -4378,7 +4378,7 @@ try { if ( postYangpingProgressState?.activeTab !== 'progress' || postYangpingProgressState.campaignProgress?.completedKnown !== 35 || - postYangpingProgressState.campaignProgress?.totalKnown !== 38 || + postYangpingProgressState.campaignProgress?.totalKnown !== 39 || postYangpingProgressState.campaignProgress?.activeChapter?.title !== '촉한 건국' || postYangpingProgressState.campaignProgress?.latestBattleTitle !== '양평관 정찰전' || postYangpingProgressState.campaignProgress?.nextBattleTitle !== '정군산 전초전' @@ -4535,7 +4535,7 @@ try { if ( postDingjunProgressState?.activeTab !== 'progress' || postDingjunProgressState.campaignProgress?.completedKnown !== 36 || - postDingjunProgressState.campaignProgress?.totalKnown !== 38 || + postDingjunProgressState.campaignProgress?.totalKnown !== 39 || postDingjunProgressState.campaignProgress?.activeChapter?.title !== '촉한 건국' || postDingjunProgressState.campaignProgress?.latestBattleTitle !== '정군산 전초전' || postDingjunProgressState.campaignProgress?.nextBattleTitle !== '한중 결전' @@ -4688,7 +4688,7 @@ try { if ( postHanzhongProgressState?.activeTab !== 'progress' || postHanzhongProgressState.campaignProgress?.completedKnown !== 37 || - postHanzhongProgressState.campaignProgress?.totalKnown !== 38 || + postHanzhongProgressState.campaignProgress?.totalKnown !== 39 || postHanzhongProgressState.campaignProgress?.activeChapter?.title !== '촉한 건국' || postHanzhongProgressState.campaignProgress?.latestBattleTitle !== '한중 결전' || postHanzhongProgressState.campaignProgress?.nextBattleTitle !== '한중왕 즉위 준비' @@ -4749,7 +4749,7 @@ try { if ( postKingCouncilProgressState?.activeTab !== 'progress' || postKingCouncilProgressState.campaignProgress?.completedKnown !== 37 || - postKingCouncilProgressState.campaignProgress?.totalKnown !== 38 || + postKingCouncilProgressState.campaignProgress?.totalKnown !== 39 || postKingCouncilProgressState.campaignProgress?.latestBattleTitle !== '한중 결전' || postKingCouncilProgressState.campaignProgress?.nextBattleTitle !== '촉한 건국 선포' ) { @@ -4809,7 +4809,7 @@ try { if ( postFoundationProgressState?.activeTab !== 'progress' || postFoundationProgressState.campaignProgress?.completedKnown !== 37 || - postFoundationProgressState.campaignProgress?.totalKnown !== 38 || + postFoundationProgressState.campaignProgress?.totalKnown !== 39 || postFoundationProgressState.campaignProgress?.latestBattleTitle !== '한중 결전' || postFoundationProgressState.campaignProgress?.nextBattleTitle !== '형주 방위 전초전' ) { @@ -4960,14 +4960,167 @@ try { if ( postJingDefenseProgressState?.activeTab !== 'progress' || postJingDefenseProgressState.campaignProgress?.completedKnown !== 38 || - postJingDefenseProgressState.campaignProgress?.totalKnown !== 38 || + postJingDefenseProgressState.campaignProgress?.totalKnown !== 39 || postJingDefenseProgressState.campaignProgress?.latestBattleTitle !== '형주 방위 전초전' || - postJingDefenseProgressState.campaignProgress?.nextBattleTitle !== '준비 중' + postJingDefenseProgressState.campaignProgress?.nextBattleTitle !== '번성 외곽 압박전' ) { - throw new Error(`Expected post-Jing defense progress tab to complete the thirty-eighth battle and leave Fan Castle as the next prepared slice: ${JSON.stringify(postJingDefenseProgressState?.campaignProgress)}`); + throw new Error(`Expected post-Jing defense progress tab to complete the thirty-eighth battle and point to Fan Castle vanguard: ${JSON.stringify(postJingDefenseProgressState?.campaignProgress)}`); } await page.screenshot({ path: 'dist/verification-post-jing-defense-progress.png', fullPage: true }); + await page.mouse.click(1120, 38); + await page.waitForTimeout(180); + const fanCastleSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if ( + !fanCastleSortieState?.sortieVisible || + fanCastleSortieState.campaign?.step !== 'thirty-eighth-camp' || + fanCastleSortieState.sortiePlan?.objectiveLine !== '번성 외곽 압박전 · 방덕 선봉 격파' || + fanCastleSortieState.sortiePlan?.maxCount !== 7 || + fanCastleSortieState.sortiePlan?.recommendedTotal !== 7 || + !fanCastleSortieState.sortieRoster?.some((unit) => unit.id === 'guan-yu' && unit.selected && unit.required) + ) { + throw new Error(`Expected Fan Castle sortie prep to lock Guan Yu and expose a seven-officer choice: ${JSON.stringify(fanCastleSortieState)}`); + } + assertSortieTacticalRoster(fanCastleSortieState, [ + 'liu-bei', + 'guan-yu', + 'zhang-fei', + 'jian-yong', + 'mi-zhu', + 'sun-qian', + 'zhao-yun', + 'zhuge-liang', + 'ma-liang', + 'yi-ji', + 'gong-zhi', + 'huang-zhong', + 'wei-yan', + 'pang-tong', + 'fa-zheng', + 'wu-yi', + 'yan-yan', + 'li-yan', + 'huang-quan', + 'ma-chao', + 'ma-dai', + 'wang-ping' + ]); + + const fanCastlePriorityUnits = ['zhang-fei', 'zhao-yun', 'huang-zhong', 'fa-zheng', 'ma-liang']; + for (const unitId of fanCastlePriorityUnits) { + const currentSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if (!currentSortieState.sortieRoster?.some((unit) => unit.id === unitId && unit.selected)) { + const removable = currentSortieState.sortieRoster?.find( + (unit) => unit.selected && !unit.required && !fanCastlePriorityUnits.includes(unit.id) + ); + if (removable) { + await clickSortieRosterUnit(page, removable.id); + } + await clickSortieRosterUnit(page, unitId); + } + } + + const fanCastleSortieReadyState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if ( + !fanCastlePriorityUnits.every((unitId) => + fanCastleSortieReadyState.sortieRoster?.some((unit) => unit.id === unitId && unit.selected) + ) || + !fanCastleSortieReadyState.sortieRoster?.some((unit) => unit.id === 'liu-bei' && unit.selected && unit.required) || + !fanCastleSortieReadyState.sortieRoster?.some((unit) => unit.id === 'guan-yu' && unit.selected && unit.required) || + fanCastleSortieReadyState.sortiePlan?.selectedCount !== 7 || + fanCastleSortieReadyState.sortiePlan?.recommendedSelectedCount < 7 + ) { + throw new Error(`Expected Fan Castle sortie to deploy Liu Bei, Guan Yu, Zhang Fei, Zhao Yun, Huang Zhong, Fa Zheng, and Ma Liang: ${JSON.stringify(fanCastleSortieReadyState)}`); + } + await page.screenshot({ path: 'dist/verification-fan-castle-sortie.png', fullPage: true }); + + await page.mouse.click(1068, 646); + await page.waitForFunction(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + return activeScenes.includes('StoryScene'); + }); + await page.screenshot({ path: 'dist/verification-fan-castle-story.png', fullPage: true }); + + for (let i = 0; i < 45; i += 1) { + const enteredThirtyNinthBattle = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.battle(); + return state?.scene === 'BattleScene' && state?.battleId === 'thirty-ninth-battle-fan-castle-vanguard'; + }); + if (enteredThirtyNinthBattle) { + 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 === 'thirty-ninth-battle-fan-castle-vanguard' && state?.battleOutcome === null && state?.phase === 'idle'; + }); + await page.screenshot({ path: 'dist/verification-thirty-ninth-battle.png', fullPage: true }); + + const thirtyNinthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + const thirtyNinthEnemies = thirtyNinthBattleState.units.filter((unit) => unit.faction === 'enemy'); + const thirtyNinthAllies = thirtyNinthBattleState.units.filter((unit) => unit.faction === 'ally'); + const thirtyNinthEnemyBehaviors = new Set(thirtyNinthEnemies.map((unit) => unit.ai)); + if ( + thirtyNinthBattleState.camera?.mapWidth !== 78 || + thirtyNinthBattleState.camera?.mapHeight !== 60 || + thirtyNinthBattleState.victoryConditionLabel !== '방덕 선봉 격파' || + thirtyNinthEnemies.length < 30 || + !thirtyNinthEnemyBehaviors.has('aggressive') || + !thirtyNinthEnemyBehaviors.has('guard') || + !thirtyNinthEnemyBehaviors.has('hold') || + !thirtyNinthEnemies.some((unit) => unit.id === 'fan-castle-leader-pang-de') || + !thirtyNinthEnemies.some((unit) => unit.id === 'fan-castle-officer-cao-ren') || + !thirtyNinthEnemies.some((unit) => unit.id === 'fan-castle-officer-yu-jin') || + !thirtyNinthAllies.some((unit) => unit.id === 'guan-yu') || + !thirtyNinthAllies.some((unit) => unit.id === 'huang-zhong') || + !thirtyNinthAllies.some((unit) => unit.id === 'fa-zheng') || + !thirtyNinthAllies.some((unit) => unit.id === 'ma-liang') || + !thirtyNinthAllies.some((unit) => unit.id === 'zhao-yun') + ) { + throw new Error(`Expected thirty-ninth battle to use Fan Castle map, Pang De objective, selected allied officers, and mixed castle AI: ${JSON.stringify(thirtyNinthBattleState)}`); + } + + 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 thirtyNinthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if ( + thirtyNinthCampState?.campBattleId !== 'thirty-ninth-battle-fan-castle-vanguard' || + thirtyNinthCampState.campTitle !== '번성 외곽 압박전 후 군영' || + thirtyNinthCampState.availableDialogueIds?.length !== 3 || + !thirtyNinthCampState.availableDialogueIds.every((id) => id.endsWith('fan-castle')) || + thirtyNinthCampState.availableVisitIds?.length !== 2 || + thirtyNinthCampState.rosterCollection?.total < 22 + ) { + throw new Error(`Expected thirty-ninth camp to expose Fan Castle dialogue/visit sets and preserve the full officer roster: ${JSON.stringify(thirtyNinthCampState)}`); + } + await page.screenshot({ path: 'dist/verification-fan-castle-camp.png', fullPage: true }); + + await page.mouse.click(966, 38); + await page.waitForTimeout(180); + const postFanCastleProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if ( + postFanCastleProgressState?.activeTab !== 'progress' || + postFanCastleProgressState.campaignProgress?.completedKnown !== 39 || + postFanCastleProgressState.campaignProgress?.totalKnown !== 39 || + postFanCastleProgressState.campaignProgress?.latestBattleTitle !== '번성 외곽 압박전' || + postFanCastleProgressState.campaignProgress?.nextBattleTitle !== '준비 중' + ) { + throw new Error(`Expected post-Fan Castle progress tab to complete the thirty-ninth battle and leave Han River flood as the next prepared slice: ${JSON.stringify(postFanCastleProgressState?.campaignProgress)}`); + } + await page.screenshot({ path: 'dist/verification-post-fan-castle-progress.png', fullPage: true }); + await page.evaluate(() => { const game = window.__HEROS_GAME__; game?.scene.stop('CampScene'); @@ -4979,14 +5132,31 @@ try { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('TitleScene') && !activeScenes.includes('CampScene'); }); - await page.waitForTimeout(300); - await page.mouse.click(962, 310); - await page.waitForFunction(() => { - const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; - return activeScenes.includes('CampScene'); - }); + await page.waitForTimeout(800); + for (const [x, y] of [ + [962, 310], + [962, 306], + [962, 314] + ]) { + await page.mouse.click(x, y); + await page.waitForTimeout(300); + const continuedToCamp = await page.evaluate(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + return activeScenes.includes('CampScene'); + }); + if (continuedToCamp) { + break; + } + } + const titleContinueState = await page.evaluate(() => ({ + activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [], + camp: window.__HEROS_DEBUG__?.camp?.() ?? null + })); + if (!titleContinueState.activeScenes.includes('CampScene')) { + throw new Error(`Expected title continue to reopen the latest camp after Fan Castle: ${JSON.stringify(titleContinueState)}`); + } - console.log(`Verified title-to-thirty-eighth-battle flow, recruited officer sortie selection, Ma Liang, Yi Ji, Gong Zhi, Huang Zhong, Wei Yan, Pang Tong, Fa Zheng, Wu Yi, Yan Yan, Li Yan, Huang Quan, Ma Chao, Ma Dai, Wang Ping joins, Luofeng, Luo main gate, Mianzhu, Chengdu surrender, Jiameng, Yangping, Dingjun, Hanzhong decisive battle, King of Hanzhong council, Shu-Han foundation state, and Jing Province defense vanguard state at ${targetUrl}`); + console.log(`Verified title-to-thirty-ninth-battle flow, recruited officer sortie selection, Ma Liang, Yi Ji, Gong Zhi, Huang Zhong, Wei Yan, Pang Tong, Fa Zheng, Wu Yi, Yan Yan, Li Yan, Huang Quan, Ma Chao, Ma Dai, Wang Ping joins, Luofeng, Luo main gate, Mianzhu, Chengdu surrender, Jiameng, Yangping, Dingjun, Hanzhong decisive battle, King of Hanzhong council, Shu-Han foundation state, Jing Province defense vanguard, and Fan Castle vanguard state at ${targetUrl}`); } finally { await browser?.close(); if (serverProcess && !serverProcess.killed) { diff --git a/src/assets/images/battle/thirty-ninth-battle-map.svg b/src/assets/images/battle/thirty-ninth-battle-map.svg new file mode 100644 index 0000000..cafb864 --- /dev/null +++ b/src/assets/images/battle/thirty-ninth-battle-map.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index 0666a0a..285ecfc 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -51,6 +51,10 @@ import { thirtyEighthBattleMap, thirtyEighthBattleUnits, thirtyEighthBattleVictoryPages, + thirtyNinthBattleBonds, + thirtyNinthBattleMap, + thirtyNinthBattleUnits, + thirtyNinthBattleVictoryPages, thirtyFourthBattleBonds, thirtyFourthBattleMap, thirtyFourthBattleUnits, @@ -195,7 +199,8 @@ export type BattleScenarioId = | 'thirty-fifth-battle-yangping-scout' | 'thirty-sixth-battle-dingjun-vanguard' | 'thirty-seventh-battle-hanzhong-decisive' - | 'thirty-eighth-battle-jing-defense'; + | 'thirty-eighth-battle-jing-defense' + | 'thirty-ninth-battle-fan-castle-vanguard'; export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory'; @@ -2449,6 +2454,69 @@ export const thirtyEighthBattleScenario: BattleScenarioDefinition = { nextCampScene: 'CampScene' }; +export const thirtyNinthBattleScenario: BattleScenarioDefinition = { + id: 'thirty-ninth-battle-fan-castle-vanguard', + title: '번성 외곽 압박전', + victoryConditionLabel: '방덕 선봉 격파', + defeatConditionLabel: '유비 또는 관우 퇴각', + openingObjectiveLines: [ + '형주 방위가 안정된 사이 관우는 번성 외곽으로 북상했습니다. 조조군의 보루와 나루를 먼저 장악해야 다음 결전을 열 수 있습니다.', + '이번 전투는 관우가 핵심입니다. 관우가 무너지면 번성 전선의 사기가 흔들리니, 유비와 관우를 함께 지키며 전선을 밀어 올리십시오.', + '성채 안쪽의 조인, 수로를 보는 우금, 기병을 이끄는 방덕이 서로 다른 방식으로 버틸 것입니다. 기동대와 책사를 골고루 출전시키면 공명과 성장 기회가 커집니다.' + ], + map: thirtyNinthBattleMap, + units: thirtyNinthBattleUnits, + bonds: thirtyNinthBattleBonds, + mapTextureKey: 'battle-map-thirty-ninth', + leaderUnitId: 'fan-castle-leader-pang-de', + quickVictoryTurnLimit: 62, + baseVictoryGold: 7600, + objectives: [ + { + id: 'leader', + kind: 'defeat-leader', + label: '방덕 선봉 격파', + rewardGold: 4800, + unitId: 'fan-castle-leader-pang-de' + }, + { + id: 'liu-bei', + kind: 'keep-unit-alive', + label: '유비 생존', + rewardGold: 1200, + unitId: 'liu-bei' + }, + { + id: 'guan-yu', + kind: 'keep-unit-alive', + label: '관우 생존', + rewardGold: 1900, + unitId: 'guan-yu' + }, + { + id: 'outer-fort', + kind: 'secure-terrain', + label: '번성 외곽 보루 확보', + rewardGold: 2320, + terrain: 'fort' + }, + { + id: 'quick', + kind: 'quick-victory', + label: '62턴 이내 승리', + rewardGold: 1720, + maxTurn: 62 + } + ], + defeatConditions: [ + { kind: 'unit-defeated', unitId: 'liu-bei' }, + { kind: 'unit-defeated', unitId: 'guan-yu' } + ], + itemRewards: ['콩 34', '상처약 25', '탁주 10', '번성 외곽도 1', '한수 수공 준비'], + victoryPages: thirtyNinthBattleVictoryPages, + nextCampScene: 'CampScene' +}; + export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id; export const battleScenarios: Record = { @@ -2489,7 +2557,8 @@ export const battleScenarios: Record 'thirty-fifth-battle-yangping-scout': thirtyFifthBattleScenario, 'thirty-sixth-battle-dingjun-vanguard': thirtySixthBattleScenario, 'thirty-seventh-battle-hanzhong-decisive': thirtySeventhBattleScenario, - 'thirty-eighth-battle-jing-defense': thirtyEighthBattleScenario + 'thirty-eighth-battle-jing-defense': thirtyEighthBattleScenario, + 'thirty-ninth-battle-fan-castle-vanguard': thirtyNinthBattleScenario }; export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId]; diff --git a/src/game/data/campaignFlow.ts b/src/game/data/campaignFlow.ts index 4c62e88..5d388f1 100644 --- a/src/game/data/campaignFlow.ts +++ b/src/game/data/campaignFlow.ts @@ -21,6 +21,7 @@ import { thirtyFourthBattleScenario, thirtyEighthBattleScenario, thirtyFirstBattleScenario, + thirtyNinthBattleScenario, thirtySecondBattleScenario, thirtyThirdBattleScenario, thirtySixthBattleScenario, @@ -84,6 +85,8 @@ import { thirtyEighthBattleVictoryPages, thirtyFirstBattleIntroPages, thirtyFirstBattleVictoryPages, + thirtyNinthBattleIntroPages, + thirtyNinthBattleVictoryPages, thirtySecondBattleIntroPages, thirtySecondBattleVictoryPages, thirtyThirdBattleIntroPages, @@ -552,13 +555,24 @@ const sortieFlows: Record = { }, [thirtyEighthBattleScenario.id]: { afterBattleId: thirtyEighthBattleScenario.id, - eyebrow: '다음 장 준비', - title: '번성 전선 준비', + eyebrow: '다음 전장', + title: thirtyNinthBattleScenario.title, description: - '형주 봉화대와 나루를 안정시켰지만, 북쪽 번성의 문과 오군의 시선은 더 큰 압박으로 남았습니다. 다음은 관우의 형주 전선이 본격적으로 시험받는 흐름입니다.', - rewardHint: '다음 장: 번성 전선 준비 중', - pages: thirtyEighthBattleVictoryPages, - unavailableNotice: '번성 전선과 관우의 북상은 다음 작업에서 이어집니다. 형주 방위, 강변 민심, 오·위 양면 압박을 준비하십시오.' + '형주 봉화대와 나루를 안정시켰지만 번성은 아직 닫혀 있습니다. 다음 출진에서는 관우를 축으로 번성 외곽의 보루와 수로를 눌러, 한수 수공과 성문 공략의 발판을 마련합니다.', + rewardHint: `예상 보상: ${thirtyNinthBattleScenario.title} 개방 / 번성 외곽도`, + nextBattleId: thirtyNinthBattleScenario.id, + campaignStep: 'thirty-ninth-battle', + pages: [...thirtyEighthBattleVictoryPages, ...thirtyNinthBattleIntroPages] + }, + [thirtyNinthBattleScenario.id]: { + afterBattleId: thirtyNinthBattleScenario.id, + eyebrow: '다음 장 준비', + title: '한수 수공 준비', + description: + '번성 외곽의 보루를 눌렀지만 조인과 우금의 본대는 아직 성 안과 수로를 지키고 있습니다. 다음 장에서는 한수의 물길, 둑, 성벽을 함께 다루는 더 큰 전장이 이어집니다.', + rewardHint: '다음 장: 한수 수공과 칠군 침수 준비 중', + pages: thirtyNinthBattleVictoryPages, + unavailableNotice: '한수 수공과 칠군 침수 전장은 다음 작업에서 이어집니다. 지금은 군영에서 무장 성장, 공명도, 보급을 정비할 수 있습니다.' } }; diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index 66d3d49..0c9f588 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -2525,6 +2525,70 @@ export const thirtyEighthBattleVictoryPages: StoryPage[] = [ } ]; +export const thirtyNinthBattleIntroPages: StoryPage[] = [ + { + id: 'thirty-ninth-fan-castle-shadow', + bgm: 'story-dark', + chapter: '번성의 그림자', + background: 'story-three-heroes', + speaker: '제갈량', + text: '형주의 봉화는 다시 올랐으나, 번성의 문은 더욱 굳게 닫혔습니다. 조조의 북방 병력은 수로와 성채를 엮어 시간을 벌려 하고, 오의 시선도 강 건너에서 조용히 움직입니다.' + }, + { + id: 'thirty-ninth-guan-yu-north', + bgm: 'battle-prep', + chapter: '관우의 북상', + background: 'story-liu-bei', + speaker: '관우', + portrait: 'guanYu', + text: '형주를 지키는 일은 곧 한실을 일으키는 일입니다. 번성 외곽의 보루와 나루를 눌러야 적의 숨통을 조이고, 뒤이어 큰 결전을 열 수 있습니다.' + }, + { + id: 'thirty-ninth-flooded-fields', + bgm: 'battle-prep', + chapter: '물길을 보는 눈', + background: 'story-militia', + speaker: '법정', + text: '한수의 물길은 평소에는 느려 보이나, 비와 둑의 높이에 따라 전장의 형세를 통째로 바꿉니다. 이번 전투에서는 성 밖 보급선과 제방 감시초를 먼저 잡아야 합니다.' + }, + { + id: 'thirty-ninth-sortie', + bgm: 'battle-prep', + chapter: '번성 외곽 압박전', + background: 'story-sortie', + speaker: '유비', + portrait: 'liuBei', + text: '관우가 선봉을 잡되 모두가 그 뒤를 받쳐라. 번성 외곽을 열면 다음 물길과 성문을 함께 도모할 수 있다. 오늘의 선택이 다음 전장의 폭을 넓힐 것이다.' + } +]; + +export const thirtyNinthBattleVictoryPages: StoryPage[] = [ + { + id: 'thirty-ninth-victory-outer-wall', + bgm: 'militia-theme', + chapter: '외곽 보루 함락', + background: 'story-sortie', + text: '번성 외곽의 보루와 나루가 촉한군의 손에 들어왔습니다. 조조군은 성 안쪽으로 물러났고, 방덕의 선봉은 다시 방어진을 고쳐 세우며 다음 격돌을 예고했습니다.' + }, + { + id: 'thirty-ninth-pang-de-resolve', + bgm: 'battle-prep', + chapter: '방덕의 결의', + background: 'story-three-heroes', + speaker: '제갈량', + text: '방덕은 물러났으나 기세를 잃지 않았습니다. 조인과 우금도 성 안의 병력을 다시 묶고 있으니, 다음 전투에서는 물길과 성벽이 함께 움직일 것입니다.' + }, + { + id: 'thirty-ninth-next-water-lines', + bgm: 'story-dark', + chapter: '한수 수공 준비', + background: 'story-militia', + speaker: '관우', + portrait: 'guanYu', + text: '성은 아직 멀리 있고, 강은 우리 곁에 있소. 병사들에게 둑을 살피게 하고 배와 밧줄을 준비하라. 다음에는 물길마저 우리 편으로 삼아야 하오.' + } +]; + export const firstBattleMap: BattleMap = { width: 20, height: 18, @@ -2898,6 +2962,12 @@ export const thirtyEighthBattleMap: BattleMap = { terrain: createThirtyEighthBattleTerrain() }; +export const thirtyNinthBattleMap: BattleMap = { + width: 78, + height: 60, + terrain: createThirtyNinthBattleTerrain() +}; + export const firstBattleUnits: UnitData[] = [ { id: 'liu-bei', @@ -14199,6 +14269,84 @@ export const thirtyEighthBattleUnits: UnitData[] = [ createThirtyFirstEnemyUnit('jing-defense-leader-lu-meng', '여몽', '오군 도독', 'rebelLeader', 88, 520, 140, 72, 35) ]; +const thirtyNinthBattleAllyPositions: Record = { + 'liu-bei': { x: 5, y: 54 }, + 'guan-yu': { x: 10, y: 49 }, + 'zhang-fei': { x: 6, y: 56 }, + 'jian-yong': { x: 4, y: 57 }, + 'mi-zhu': { x: 8, y: 55 }, + 'sun-qian': { x: 9, y: 58 }, + 'zhao-yun': { x: 13, y: 52 }, + 'zhuge-liang': { x: 9, y: 51 }, + 'ma-liang': { x: 12, y: 54 }, + 'yi-ji': { x: 14, y: 57 }, + 'gong-zhi': { x: 15, y: 55 }, + 'huang-zhong': { x: 16, y: 50 }, + 'wei-yan': { x: 14, y: 48 }, + 'pang-tong': { x: 11, y: 52 }, + 'fa-zheng': { x: 17, y: 54 }, + 'wu-yi': { x: 18, y: 51 }, + 'yan-yan': { x: 19, y: 53 }, + 'li-yan': { x: 17, y: 48 }, + 'huang-quan': { x: 20, y: 55 }, + 'ma-chao': { x: 21, y: 49 }, + 'ma-dai': { x: 22, y: 51 }, + 'wang-ping': { x: 20, y: 47 } +}; + +export const thirtyNinthBattleUnits: UnitData[] = [ + ...[ + ...firstBattleUnits.filter((unit) => unit.faction === 'ally'), + ...xuzhouRecruitUnits, + ...caoBreakRecruitUnits, + ...liuBiaoRecruitUnits, + ...zhugeRecruitUnits, + ...jingzhouRecruitUnits, + ...guiyangRecruitUnits, + ...wulingRecruitUnits, + ...changshaRecruitUnits, + ...yizhouRecruitUnits, + ...fuPassRecruitUnits, + ...luoCastleRecruitUnits, + ...luoCastleProperRecruitUnits, + ...chengduPressureRecruitUnits, + ...chengduSurrenderRecruitUnits, + ...hanzhongOpeningRecruitUnits, + ...hanzhongScoutRecruitUnits, + ...hanzhongMainRecruitUnits + ].map((unit) => placeScenarioUnit(unit, thirtyNinthBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })), + createThirtyFirstEnemyUnit('fan-castle-scout-a', '조조군 척후', '강변 척후', 'bandit', 85, 306, 102, 28, 51), + createThirtyFirstEnemyUnit('fan-castle-scout-b', '조조군 척후', '제방 척후', 'bandit', 85, 306, 102, 35, 48), + createThirtyFirstEnemyUnit('fan-castle-scout-c', '조조군 척후', '북문 척후', 'bandit', 86, 314, 104, 50, 42), + createThirtyFirstEnemyUnit('fan-castle-infantry-a', '번성 보병', '하류 보병', 'yellowTurban', 86, 462, 130, 40, 44), + createThirtyFirstEnemyUnit('fan-castle-infantry-b', '번성 보병', '중로 보병', 'yellowTurban', 86, 462, 130, 48, 41), + createThirtyFirstEnemyUnit('fan-castle-infantry-c', '번성 보병', '성문 보병', 'yellowTurban', 87, 474, 132, 57, 35), + createThirtyFirstEnemyUnit('fan-castle-infantry-d', '번성 보병', '보루 보병', 'yellowTurban', 87, 474, 132, 63, 31), + createThirtyFirstEnemyUnit('fan-castle-infantry-e', '번성 보병', '상류 보병', 'yellowTurban', 88, 486, 134, 69, 25), + createThirtyFirstEnemyUnit('fan-castle-archer-a', '번성 궁병', '제방 궁병', 'archer', 86, 322, 108, 43, 46), + createThirtyFirstEnemyUnit('fan-castle-archer-b', '번성 궁병', '강안 궁병', 'archer', 87, 330, 110, 53, 38), + createThirtyFirstEnemyUnit('fan-castle-archer-c', '번성 궁병', '북문 궁병', 'archer', 87, 330, 110, 62, 33), + createThirtyFirstEnemyUnit('fan-castle-archer-d', '번성 궁병', '보루 궁병', 'archer', 88, 338, 112, 71, 27), + createThirtyFirstEnemyUnit('fan-castle-archer-e', '번성 궁병', '성벽 궁병', 'archer', 88, 338, 112, 73, 19), + createThirtyFirstEnemyUnit('fan-castle-cavalry-a', '방덕 기병', '좌익 기병', 'cavalry', 87, 474, 134, 32, 52), + createThirtyFirstEnemyUnit('fan-castle-cavalry-b', '방덕 기병', '중앙 기병', 'cavalry', 87, 474, 134, 47, 47), + createThirtyFirstEnemyUnit('fan-castle-cavalry-c', '방덕 기병', '우익 기병', 'cavalry', 88, 486, 136, 58, 41), + createThirtyFirstEnemyUnit('fan-castle-cavalry-d', '방덕 기병', '북로 기병', 'cavalry', 88, 486, 136, 68, 35), + createThirtyFirstEnemyUnit('fan-castle-guard-a', '번성 친위대', '외곽 친위대', 'yellowTurban', 89, 512, 140, 59, 32), + createThirtyFirstEnemyUnit('fan-castle-guard-b', '번성 친위대', '성문 친위대', 'yellowTurban', 89, 512, 140, 66, 28), + createThirtyFirstEnemyUnit('fan-castle-guard-c', '번성 친위대', '수문 친위대', 'yellowTurban', 90, 524, 142, 70, 22), + createThirtyFirstEnemyUnit('fan-castle-guard-d', '번성 친위대', '내성 친위대', 'yellowTurban', 90, 524, 142, 74, 17), + createThirtyFirstEnemyUnit('fan-castle-strategist-a', '조조군 책사', '수로 책사', 'strategist', 87, 338, 114, 55, 39), + createThirtyFirstEnemyUnit('fan-castle-strategist-b', '조조군 책사', '성문 책사', 'strategist', 88, 346, 116, 66, 30), + createThirtyFirstEnemyUnit('fan-castle-strategist-c', '조조군 책사', '상류 책사', 'strategist', 89, 354, 118, 72, 21), + createThirtyFirstEnemyUnit('fan-castle-ambusher-a', '번성 복병', '숲길 복병', 'bandit', 86, 316, 106, 38, 53), + createThirtyFirstEnemyUnit('fan-castle-ambusher-b', '번성 복병', '둑길 복병', 'bandit', 87, 324, 108, 52, 45), + createThirtyFirstEnemyUnit('fan-castle-ambusher-c', '번성 복병', '나루 복병', 'bandit', 87, 324, 108, 64, 40), + createThirtyFirstEnemyUnit('fan-castle-officer-yu-jin', '우금', '조조군 도독', 'rebelLeader', 90, 540, 144, 69, 24), + createThirtyFirstEnemyUnit('fan-castle-officer-cao-ren', '조인', '번성 수비장', 'rebelLeader', 91, 552, 146, 73, 18), + createThirtyFirstEnemyUnit('fan-castle-leader-pang-de', '방덕', '번성 선봉장', 'cavalry', 92, 566, 150, 71, 31) +]; + export const firstBattleBonds: BattleBond[] = [ { id: 'liu-bei__guan-yu', @@ -14739,6 +14887,25 @@ export const jingDefenseBonds: BattleBond[] = [ } ]; +export const fanCastleBonds: BattleBond[] = [ + { + id: 'guan-yu__fa-zheng', + unitIds: ['guan-yu', 'fa-zheng'], + title: '번성 책략선', + level: 27, + exp: 0, + description: '관우의 정면 압박과 법정의 물길 판단이 맞물리면, 번성 외곽의 보루와 나루를 동시에 흔드는 공명 효과가 강해집니다.' + }, + { + id: 'guan-yu__ma-chao', + unitIds: ['guan-yu', 'ma-chao'], + title: '청룡과 서량 기병', + level: 26, + exp: 0, + description: '관우의 묵직한 전선 유지와 마초의 거친 기동력이 이어지면, 방덕 기병대의 돌파를 맞받아치는 연계 공격 확률이 높아집니다.' + } +]; + export const secondBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); export const thirdBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); export const fourthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); @@ -14798,6 +14965,9 @@ export const thirtySeventhBattleBonds: BattleBond[] = [ export const thirtyEighthBattleBonds: BattleBond[] = [...thirtySeventhBattleBonds, ...jingDefenseBonds].map( cloneBattleBondForScenario ); +export const thirtyNinthBattleBonds: BattleBond[] = [...thirtyEighthBattleBonds, ...fanCastleBonds].map( + cloneBattleBondForScenario +); function createEighthBattleTerrain(): TerrainType[][] { return Array.from({ length: 22 }, (_, y) => @@ -16547,6 +16717,78 @@ function createThirtyEighthBattleTerrain(): TerrainType[][] { ); } +function createThirtyNinthBattleTerrain(): TerrainType[][] { + return Array.from({ length: 60 }, (_, y) => + Array.from({ length: 78 }, (_, x): TerrainType => { + if (x <= 6 && y >= 53) { + return 'camp'; + } + if ( + (x >= 60 && x <= 77 && y >= 6 && y <= 24) || + (x >= 53 && x <= 65 && y >= 9 && y <= 22) || + (x >= 66 && x <= 77 && y >= 20 && y <= 38) || + (x >= 68 && x <= 74 && y >= 34 && y <= 44) + ) { + return 'fort'; + } + if ( + (x >= 21 && x <= 25 && y >= 50 && y <= 54) || + (x >= 42 && x <= 46 && y >= 43 && y <= 46) || + (x >= 56 && x <= 60 && y >= 36 && y <= 40) || + (x >= 70 && x <= 73 && y >= 40 && y <= 44) + ) { + return 'village'; + } + if ( + (y >= 55 && x >= 6 && x <= 27) || + (x >= 26 && x <= 42 && y >= 50 && y <= 55) || + (x >= 41 && x <= 56 && y >= 44 && y <= 50) || + (x >= 55 && x <= 68 && y >= 38 && y <= 44) || + (x >= 67 && x <= 76 && y >= 30 && y <= 38) || + (x >= 70 && x <= 77 && y >= 18 && y <= 30) || + (x >= 58 && x <= 75 && y >= 16 && y <= 20) + ) { + return 'road'; + } + if ((x === 19 || x === 20 || x === 21) && y >= 0 && y <= 51) { + return 'river'; + } + if ((x === 48 || x === 49) && y >= 6 && y <= 44) { + return 'river'; + } + if ((x === 59 || x === 60) && y >= 40 && y <= 59) { + return 'river'; + } + if ( + (x >= 4 && x <= 28 && y >= 10 && y <= 42) || + (x >= 29 && x <= 44 && y >= 18 && y <= 46) || + (x >= 49 && x <= 64 && y >= 44 && y <= 59) || + (x >= 61 && x <= 77 && y >= 41 && y <= 58) + ) { + return 'forest'; + } + if ( + (x >= 0 && x <= 18 && y <= 26) || + (x >= 28 && x <= 46 && y <= 17) || + (x >= 47 && x <= 65 && y >= 1 && y <= 19) || + (x >= 66 && x <= 77 && y <= 14) || + (x >= 32 && x <= 50 && y >= 52 && y <= 59) + ) { + return 'hill'; + } + if ( + (x <= 2 && y <= 52) || + (x >= 75 && y >= 36) || + (x >= 58 && x <= 62 && y <= 8) || + (x >= 27 && x <= 32 && y >= 27 && y <= 41) + ) { + return 'cliff'; + } + return 'plain'; + }) + ); +} + function placeScenarioUnit(unit: UnitData, position: { x: number; y: number }): UnitData { return { ...cloneUnitForScenario(unit), diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 896b1ef..f10fc63 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -615,7 +615,37 @@ const unitTexture: Record = { 'jing-defense-ambusher-c': 'unit-rebel', 'jing-defense-officer-lu-xun': 'unit-rebel-archer', 'jing-defense-officer-cao-ren': 'unit-rebel-leader', - 'jing-defense-leader-lu-meng': 'unit-rebel-leader' + 'jing-defense-leader-lu-meng': 'unit-rebel-leader', + 'fan-castle-scout-a': 'unit-rebel', + 'fan-castle-scout-b': 'unit-rebel', + 'fan-castle-scout-c': 'unit-rebel', + 'fan-castle-infantry-a': 'unit-rebel', + 'fan-castle-infantry-b': 'unit-rebel', + 'fan-castle-infantry-c': 'unit-rebel', + 'fan-castle-infantry-d': 'unit-rebel', + 'fan-castle-infantry-e': 'unit-rebel', + 'fan-castle-archer-a': 'unit-rebel-archer', + 'fan-castle-archer-b': 'unit-rebel-archer', + 'fan-castle-archer-c': 'unit-rebel-archer', + 'fan-castle-archer-d': 'unit-rebel-archer', + 'fan-castle-archer-e': 'unit-rebel-archer', + 'fan-castle-cavalry-a': 'unit-rebel-cavalry', + 'fan-castle-cavalry-b': 'unit-rebel-cavalry', + 'fan-castle-cavalry-c': 'unit-rebel-cavalry', + 'fan-castle-cavalry-d': 'unit-rebel-cavalry', + 'fan-castle-guard-a': 'unit-rebel', + 'fan-castle-guard-b': 'unit-rebel', + 'fan-castle-guard-c': 'unit-rebel', + 'fan-castle-guard-d': 'unit-rebel', + 'fan-castle-strategist-a': 'unit-rebel-archer', + 'fan-castle-strategist-b': 'unit-rebel-archer', + 'fan-castle-strategist-c': 'unit-rebel-archer', + 'fan-castle-ambusher-a': 'unit-rebel', + 'fan-castle-ambusher-b': 'unit-rebel', + 'fan-castle-ambusher-c': 'unit-rebel', + 'fan-castle-officer-yu-jin': 'unit-rebel-leader', + 'fan-castle-officer-cao-ren': 'unit-rebel-leader', + 'fan-castle-leader-pang-de': 'unit-rebel-cavalry' }; const unitTextureByClass: Partial> = { @@ -1688,7 +1718,37 @@ const enemyAiByUnitId: Record = { 'jing-defense-ambusher-c': 'aggressive', 'jing-defense-officer-lu-xun': 'hold', 'jing-defense-officer-cao-ren': 'guard', - 'jing-defense-leader-lu-meng': 'aggressive' + 'jing-defense-leader-lu-meng': 'aggressive', + 'fan-castle-scout-a': 'aggressive', + 'fan-castle-scout-b': 'aggressive', + 'fan-castle-scout-c': 'aggressive', + 'fan-castle-infantry-a': 'guard', + 'fan-castle-infantry-b': 'guard', + 'fan-castle-infantry-c': 'guard', + 'fan-castle-infantry-d': 'guard', + 'fan-castle-infantry-e': 'guard', + 'fan-castle-archer-a': 'hold', + 'fan-castle-archer-b': 'hold', + 'fan-castle-archer-c': 'hold', + 'fan-castle-archer-d': 'hold', + 'fan-castle-archer-e': 'hold', + 'fan-castle-cavalry-a': 'aggressive', + 'fan-castle-cavalry-b': 'aggressive', + 'fan-castle-cavalry-c': 'aggressive', + 'fan-castle-cavalry-d': 'aggressive', + 'fan-castle-guard-a': 'guard', + 'fan-castle-guard-b': 'guard', + 'fan-castle-guard-c': 'guard', + 'fan-castle-guard-d': 'guard', + 'fan-castle-strategist-a': 'hold', + 'fan-castle-strategist-b': 'hold', + 'fan-castle-strategist-c': 'hold', + 'fan-castle-ambusher-a': 'aggressive', + 'fan-castle-ambusher-b': 'aggressive', + 'fan-castle-ambusher-c': 'aggressive', + 'fan-castle-officer-yu-jin': 'guard', + 'fan-castle-officer-cao-ren': 'guard', + 'fan-castle-leader-pang-de': 'aggressive' }; const defaultEnemyAiByClass: Partial> = { diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts index dd2a1b4..0673502 100644 --- a/src/game/scenes/BootScene.ts +++ b/src/game/scenes/BootScene.ts @@ -20,6 +20,7 @@ import thirtyFourthBattleMapUrl from '../../assets/images/battle/thirty-fourth-b import thirtyEighthBattleMapUrl from '../../assets/images/battle/thirty-eighth-battle-map.svg'; import thirtyFifthBattleMapUrl from '../../assets/images/battle/thirty-fifth-battle-map.svg'; import thirtyFirstBattleMapUrl from '../../assets/images/battle/thirty-first-battle-map.svg'; +import thirtyNinthBattleMapUrl from '../../assets/images/battle/thirty-ninth-battle-map.svg'; import thirtySecondBattleMapUrl from '../../assets/images/battle/thirty-second-battle-map.svg'; import thirtyThirdBattleMapUrl from '../../assets/images/battle/thirty-third-battle-map.svg'; import thirtySixthBattleMapUrl from '../../assets/images/battle/thirty-sixth-battle-map.svg'; @@ -134,6 +135,7 @@ export class BootScene extends Phaser.Scene { this.load.image('battle-map-thirty-sixth', thirtySixthBattleMapUrl); this.load.image('battle-map-thirty-seventh', thirtySeventhBattleMapUrl); this.load.image('battle-map-thirty-eighth', thirtyEighthBattleMapUrl); + this.load.image('battle-map-thirty-ninth', thirtyNinthBattleMapUrl); this.load.image('portrait-liu-bei', liuBeiPortraitUrl); this.load.image('portrait-guan-yu', guanYuPortraitUrl); this.load.image('portrait-zhang-fei', zhangFeiPortraitUrl); diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 773dbf1..6447210 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -26,6 +26,7 @@ import { hanzhongMainRecruitUnits, hanzhongScoutRecruitBonds, hanzhongScoutRecruitUnits, + fanCastleBonds, jingDefenseBonds, jingzhouRecruitBonds, jingzhouRecruitUnits, @@ -142,6 +143,7 @@ type SortieRecommendation = { type SortieRuleDefinition = { maxUnits: number; + requiredUnitIds?: string[]; recommended: SortieRecommendation[]; note: string; }; @@ -350,7 +352,8 @@ const campaignTimelineChapters: CampaignTimelineChapter[] = [ 'thirty-fifth-battle-yangping-scout', 'thirty-sixth-battle-dingjun-vanguard', 'thirty-seventh-battle-hanzhong-decisive', - 'thirty-eighth-battle-jing-defense' + 'thirty-eighth-battle-jing-defense', + 'thirty-ninth-battle-fan-castle-vanguard' ], nextHints: ['왕업 선언', '오호대장군', '북벌 준비'] } @@ -394,10 +397,11 @@ const campBattleIds = { thirtyFifth: 'thirty-fifth-battle-yangping-scout', thirtySixth: 'thirty-sixth-battle-dingjun-vanguard', thirtySeventh: 'thirty-seventh-battle-hanzhong-decisive', - thirtyEighth: 'thirty-eighth-battle-jing-defense' + thirtyEighth: 'thirty-eighth-battle-jing-defense', + thirtyNinth: 'thirty-ninth-battle-fan-castle-vanguard' } as const; -const requiredSortieUnitIds = new Set(['liu-bei']); +const defaultRequiredSortieUnitIds = ['liu-bei']; const foundingSortieUnitIds = new Set(['liu-bei', 'guan-yu', 'zhang-fei']); const maxSortieUnits = 6; const sortieFormationSlotDefinitions: Omit[] = [ @@ -409,6 +413,7 @@ const sortieFormationSlotDefinitions: Omit[] = const defaultSortieRule: SortieRuleDefinition = { maxUnits: maxSortieUnits, + requiredUnitIds: defaultRequiredSortieUnitIds, recommended: [ { unitId: 'liu-bei', reason: '패배 조건인 주장이며 공명 중심입니다.' } ], @@ -817,6 +822,7 @@ const sortieRulesByBattleId: Partial { const rowY = y + (denseRoster ? 48 : 50) + index * rowGap; const selected = this.isSortieSelected(unit.id); - const required = requiredSortieUnitIds.has(unit.id); + const required = this.isRequiredSortieUnit(unit.id); const recruited = !foundingSortieUnitIds.has(unit.id); const recommendation = this.sortieRecommendation(unit.id); const summary = this.sortieUnitTacticalSummary(unit); @@ -6601,6 +6813,14 @@ export class CampScene extends Phaser.Scene { return sortieRulesByBattleId[scenario.id] ?? defaultSortieRule; } + private requiredSortieUnitIdsFor(scenario = this.nextSortieScenario()) { + return new Set(this.nextSortieRule(scenario).requiredUnitIds ?? defaultRequiredSortieUnitIds); + } + + private isRequiredSortieUnit(unitId: string, scenario = this.nextSortieScenario()) { + return this.requiredSortieUnitIdsFor(scenario).has(unitId); + } + private sortieMaxUnits(scenario = this.nextSortieScenario()) { return Math.min(this.nextSortieRule(scenario).maxUnits, this.sortieAllies().length); } @@ -7016,7 +7236,7 @@ export class CampScene extends Phaser.Scene { private unitRosterStatus(unit: UnitData) { const selected = this.isSortieSelected(unit.id); const recommendation = this.sortieRecommendation(unit.id); - if (requiredSortieUnitIds.has(unit.id)) { + if (this.isRequiredSortieUnit(unit.id)) { return { label: '필수', tone: 'recommended' as const }; } if (selected) { @@ -7981,6 +8201,7 @@ export class CampScene extends Phaser.Scene { const maxUnits = this.sortieMaxUnits(); const useDefaultSelection = !candidateIds || candidateIds.length === 0; const candidateSet = new Set((candidateIds ?? []).filter((id) => allyIds.has(id))); + const requiredSortieUnitIds = this.requiredSortieUnitIdsFor(); requiredSortieUnitIds.forEach((id) => { if (allyIds.has(id)) { candidateSet.add(id); @@ -8009,7 +8230,7 @@ export class CampScene extends Phaser.Scene { if (!unit) { return; } - if (requiredSortieUnitIds.has(unitId)) { + if (this.isRequiredSortieUnit(unitId)) { this.showCampNotice(`${unit.name}는 반드시 출전해야 합니다.`); return; } @@ -8032,7 +8253,9 @@ export class CampScene extends Phaser.Scene { private ensureSortieSelectionSaved() { this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.selectedSortieUnitIds); - if (!this.selectedSortieUnitIds.some((id) => requiredSortieUnitIds.has(id)) || this.selectedSortieUnitIds.length === 0) { + const selected = new Set(this.selectedSortieUnitIds); + const required = [...this.requiredSortieUnitIdsFor()].filter((id) => this.sortieAllies().some((unit) => unit.id === id)); + if (!required.every((id) => selected.has(id)) || this.selectedSortieUnitIds.length === 0) { return false; } this.persistSortieSelection(); @@ -8138,7 +8361,7 @@ export class CampScene extends Phaser.Scene { name: unit.name, selected: this.isSortieSelected(unit.id), recruited: !foundingSortieUnitIds.has(unit.id), - required: requiredSortieUnitIds.has(unit.id), + required: this.isRequiredSortieUnit(unit.id), className: unit.className, classRole: getUnitClass(unit.classKey).role, formationRole: this.sortieFormationRole(unit), diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index e2f2001..f311907 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -22,6 +22,7 @@ import { thirtyFifthBattleScenario, thirtyFourthBattleScenario, thirtyFirstBattleScenario, + thirtyNinthBattleScenario, thirtySecondBattleScenario, thirtyThirdBattleScenario, thirtySixthBattleScenario, @@ -376,6 +377,7 @@ export class TitleScene extends Phaser.Scene { campaign.step === 'thirty-sixth-camp' || campaign.step === 'thirty-seventh-camp' || campaign.step === 'thirty-eighth-camp' || + campaign.step === 'thirty-ninth-camp' || campaign.step === 'hanzhong-king-camp' || campaign.step === 'shu-han-foundation-camp' ) { @@ -573,6 +575,11 @@ export class TitleScene extends Phaser.Scene { return; } + if (campaign.step === 'thirty-ninth-battle') { + this.scene.start('BattleScene', { battleId: thirtyNinthBattleScenario.id }); + return; + } + if (campaign.step === 'first-victory-story') { this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' }); return; diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 2a5754a..d0829cf 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -118,6 +118,8 @@ export type CampaignStep = | 'thirty-seventh-camp' | 'thirty-eighth-battle' | 'thirty-eighth-camp' + | 'thirty-ninth-battle' + | 'thirty-ninth-camp' | 'hanzhong-king-camp' | 'shu-han-foundation-camp'; @@ -219,7 +221,8 @@ const campaignBattleSteps: Record