Add Xuzhou breakout chapter

This commit is contained in:
2026-06-23 02:09:38 +09:00
parent 91b8abc7e3
commit 6eacee48fc
11 changed files with 739 additions and 16 deletions

View File

@@ -29,12 +29,13 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of
- Xu Province camp recruitment for Jian Yong and Mi Zhu, including new strategist/quartermaster roles, bond dialogue, and selectable sortie participation - Xu Province camp recruitment for Jian Yong and Mi Zhu, including new strategist/quartermaster roles, bond dialogue, and selectable sortie participation
- Eighth battle Xiaopei supply-road defense, where the expanded Xu Province roster can be selected into battle and Lu Bu's refuge arc is foreshadowed - Eighth battle Xiaopei supply-road defense, where the expanded Xu Province roster can be selected into battle and Lu Bu's refuge arc is foreshadowed
- Ninth battle Xuzhou gate night raid, opening Lu Bu's refuge and the internal instability that leads toward the loss of Xu Province - Ninth battle Xuzhou gate night raid, opening Lu Bu's refuge and the internal instability that leads toward the loss of Xu Province
- Tenth battle Xuzhou breakout, covering Zhang Fei's mistake, the loss of Xu Province to Lu Bu, and the retreat toward Cao Cao's shadow
- Tactical sortie preparation panel with class role, named equipment, core stats, bond partner, and next-map terrain suitability for each deployable officer - Tactical sortie preparation panel with class role, named equipment, core stats, bond partner, and next-map terrain suitability for each deployable officer
- Flow verification script from title through the ninth battle victory, recruit sortie selection, and camp save state - Flow verification script from title through the tenth battle victory, recruit sortie selection, and camp save state
## Next Steps ## Next Steps
1. Continue into Zhang Fei's mistake and the full loss of Xu Province to Lu Bu 1. Continue into Liu Bei's temporary service under Cao Cao and the tension of surviving in Xu/Hu Du politics
2. Add a chapter/progress view so long campaign arcs are easier to read between battles 2. Add a chapter/progress view so long campaign arcs are easier to read between battles
3. Apply treasure equipment effects to damage, defense, recovery, and command rules 3. Apply treasure equipment effects to damage, defense, recovery, and command rules
4. Add more recruitable officers as the campaign moves toward Cao Cao, Yuan Shao, Liu Biao, and Zhuge Liang 4. Add more recruitable officers as the campaign moves toward Cao Cao, Yuan Shao, Liu Biao, and Zhuge Liang

View File

@@ -1017,6 +1017,91 @@ try {
throw new Error(`Expected ninth camp to expose Xuzhou gate dialogue set and preserve recruits: ${JSON.stringify(ninthCampState)}`); throw new Error(`Expected ninth camp to expose Xuzhou gate dialogue set and preserve recruits: ${JSON.stringify(ninthCampState)}`);
} }
await page.mouse.click(1120, 38);
await page.waitForTimeout(160);
const ninthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
if (!ninthCampSortieState?.sortieVisible) {
throw new Error(`Expected ninth camp sortie prep overlay: ${JSON.stringify(ninthCampSortieState)}`);
}
if (!ninthCampSortieState.selectedSortieUnitIds?.includes('guan-yu')) {
await page.mouse.click(256, 420);
await page.waitForTimeout(120);
}
const ninthCampGuanYuSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
if (!ninthCampGuanYuSortieState.selectedSortieUnitIds?.includes('guan-yu')) {
throw new Error(`Expected Guan Yu to be selectable again for Xuzhou breakout: ${JSON.stringify(ninthCampGuanYuSortieState)}`);
}
assertSortieTacticalRoster(ninthCampGuanYuSortieState, ['liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu']);
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-xuzhou-loss-story.png', fullPage: true });
for (let i = 0; i < 14; i += 1) {
const enteredTenthBattle = await page.evaluate(() => {
const state = window.__HEROS_DEBUG__?.battle();
return state?.scene === 'BattleScene' && state?.battleId === 'tenth-battle-xuzhou-breakout';
});
if (enteredTenthBattle) {
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 === 'tenth-battle-xuzhou-breakout' && state?.battleOutcome === null && state?.phase === 'idle';
});
await page.screenshot({ path: 'dist/verification-tenth-battle.png', fullPage: true });
const tenthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
const tenthEnemies = tenthBattleState.units.filter((unit) => unit.faction === 'enemy');
const tenthAllies = tenthBattleState.units.filter((unit) => unit.faction === 'ally');
const tenthEnemyBehaviors = new Set(tenthEnemies.map((unit) => unit.ai));
if (
tenthBattleState.camera?.mapWidth !== 28 ||
tenthBattleState.camera?.mapHeight !== 22 ||
tenthBattleState.victoryConditionLabel !== '송헌 격파' ||
tenthEnemies.length < 11 ||
!tenthEnemyBehaviors.has('aggressive') ||
!tenthEnemyBehaviors.has('guard') ||
!tenthEnemyBehaviors.has('hold') ||
!tenthAllies.some((unit) => unit.id === 'liu-bei') ||
!tenthAllies.some((unit) => unit.id === 'guan-yu') ||
!tenthAllies.some((unit) => unit.id === 'zhang-fei') ||
!tenthAllies.some((unit) => unit.id === 'jian-yong') ||
!tenthAllies.some((unit) => unit.id === 'mi-zhu')
) {
throw new Error(`Expected tenth battle to use breakout map, mixed AI, and selected full Xu Province sortie: ${JSON.stringify(tenthBattleState)}`);
}
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 tenthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
if (
tenthCampState?.campBattleId !== 'tenth-battle-xuzhou-breakout' ||
tenthCampState.campTitle !== '서주 상실 후 군영' ||
tenthCampState.availableDialogueIds?.length !== 3 ||
!tenthCampState.availableDialogueIds.every((id) => id.endsWith('xuzhou-loss')) ||
!tenthCampState.campaign?.roster?.some((unit) => unit.id === 'guan-yu') ||
!tenthCampState.campaign?.roster?.some((unit) => unit.id === 'jian-yong') ||
!tenthCampState.campaign?.roster?.some((unit) => unit.id === 'mi-zhu')
) {
throw new Error(`Expected tenth camp to expose Xuzhou loss dialogue set and preserve full roster: ${JSON.stringify(tenthCampState)}`);
}
await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene')); await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene'));
await page.waitForFunction(() => { await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
@@ -1028,7 +1113,7 @@ try {
return activeScenes.includes('CampScene'); return activeScenes.includes('CampScene');
}); });
console.log(`Verified title-to-ninth-battle flow, recruit sortie selection, result states, and debug API at ${targetUrl}`); console.log(`Verified title-to-tenth-battle flow, recruit sortie selection, result states, and debug API at ${targetUrl}`);
} finally { } finally {
await browser?.close(); await browser?.close();
if (serverProcess && !serverProcess.killed) { if (serverProcess && !serverProcess.killed) {

View File

@@ -0,0 +1,97 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2800 2200" width="2800" height="2200">
<defs>
<filter id="smokeGrain">
<feTurbulence type="fractalNoise" baseFrequency="0.022" numOctaves="4" seed="1010" />
<feColorMatrix type="saturate" values="0.5" />
<feBlend mode="multiply" in2="SourceGraphic" />
</filter>
<pattern id="plain" width="100" height="100" patternUnits="userSpaceOnUse">
<rect width="100" height="100" fill="#516247" />
<path d="M10 28h26M42 68h34M64 18l18 14M20 82l22-15" stroke="#9bad6b" stroke-width="3" opacity="0.18" />
<path d="M12 56l14-22M50 38l20-18M76 80l10-24" stroke="#263922" stroke-width="2" opacity="0.36" />
</pattern>
<pattern id="road" width="112" height="112" patternUnits="userSpaceOnUse">
<rect width="112" height="112" fill="#806943" />
<path d="M0 30c42-18 78 14 112-8M0 78c44-20 76 12 112-5" stroke="#b99b66" stroke-width="8" opacity="0.24" />
<path d="M18 88l22-28M60 46l26-18M86 86l12-10" stroke="#3a2d20" stroke-width="3" opacity="0.38" />
</pattern>
<pattern id="forest" width="132" height="112" patternUnits="userSpaceOnUse">
<rect width="132" height="112" fill="#203425" />
<circle cx="24" cy="48" r="30" fill="#152616" />
<circle cx="64" cy="34" r="38" fill="#3d5a38" />
<circle cx="104" cy="54" r="31" fill="#1f3420" />
<circle cx="54" cy="84" r="28" fill="#4d6741" />
<path d="M12 66c36 18 78 16 110-10M36 26c24 20 58 18 76-8" stroke="#0b190e" stroke-width="5" opacity="0.38" />
</pattern>
<pattern id="hill" width="132" height="100" patternUnits="userSpaceOnUse">
<rect width="132" height="100" fill="#675f43" />
<path d="M-12 86c48-62 92-82 134-64c38 16 58 46 76 82" fill="#43442f" opacity="0.72" />
<path d="M18 78c38-42 74-52 110-32" fill="none" stroke="#958b58" stroke-width="10" opacity="0.28" />
</pattern>
<linearGradient id="river" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#426f88" />
<stop offset="0.52" stop-color="#1e4963" />
<stop offset="1" stop-color="#102a3e" />
</linearGradient>
<linearGradient id="wall" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#8b755f" />
<stop offset="1" stop-color="#3e3026" />
</linearGradient>
<radialGradient id="fire" cx="0.5" cy="0.38" r="0.58">
<stop offset="0" stop-color="#ffe394" />
<stop offset="0.48" stop-color="#d76c2f" />
<stop offset="1" stop-color="#4c1f16" stop-opacity="0" />
</radialGradient>
</defs>
<rect width="2800" height="2200" fill="#10171d" />
<rect width="2800" height="2200" fill="url(#plain)" opacity="0.94" />
<g filter="url(#smokeGrain)">
<path d="M-100 1500C330 1458 720 1480 1090 1496C1460 1512 1950 1500 2920 1460" fill="none" stroke="#92744c" stroke-width="258" stroke-linecap="round" opacity="0.9" />
<path d="M-100 1500C330 1458 720 1480 1090 1496C1460 1512 1950 1500 2920 1460" fill="none" stroke="url(#road)" stroke-width="154" stroke-linecap="round" opacity="0.92" />
<path d="M600 2180C610 1800 610 1420 612 1040C614 760 604 500 612 180" fill="none" stroke="#92744c" stroke-width="184" stroke-linecap="round" opacity="0.86" />
<path d="M600 2180C610 1800 610 1420 612 1040C614 760 604 500 612 180" fill="none" stroke="url(#road)" stroke-width="106" stroke-linecap="round" opacity="0.84" />
<path d="M2020 1840C2020 1480 2010 1120 2025 760C2032 574 2052 384 2036 120" fill="none" stroke="#92744c" stroke-width="178" stroke-linecap="round" opacity="0.86" />
<path d="M2020 1840C2020 1480 2010 1120 2025 760C2032 574 2052 384 2036 120" fill="none" stroke="url(#road)" stroke-width="100" stroke-linecap="round" opacity="0.82" />
<path d="M1420 2220c82-306 78-574-10-806c-78-208-62-422 48-640c86-170 100-360 42-572" fill="none" stroke="#102a3e" stroke-width="286" stroke-linecap="round" opacity="0.96" />
<path d="M1420 2220c82-306 78-574-10-806c-78-208-62-422 48-640c86-170 100-360 42-572" fill="none" stroke="url(#river)" stroke-width="182" stroke-linecap="round" opacity="0.98" />
<path d="M0 20c260-74 520-34 780 120c52 178-76 324-384 438C170 530 34 410-14 246Z" fill="url(#forest)" opacity="0.84" />
<path d="M138 760c314-116 606-62 878 158c-118 238-404 364-858 378C14 1124 8 944 138 760Z" fill="url(#forest)" opacity="0.82" />
<path d="M720 1840c230-86 450-54 660 94c-44 164-198 260-462 288c-146-70-210-194-198-382Z" fill="url(#forest)" opacity="0.78" />
<path d="M2110 36c204-54 384-22 540 92v300c-184 44-356 8-512-110c-64-90-72-184-28-282Z" fill="url(#hill)" opacity="0.82" />
<path d="M2380 1780c178-50 330-22 456 84v300c-190 54-354 24-492-90c-42-112-30-210 36-294Z" fill="url(#hill)" opacity="0.82" />
<g transform="translate(1835 455)">
<rect x="-70" y="-70" width="900" height="1120" rx="20" fill="#18120f" opacity="0.58" />
<rect x="0" y="0" width="770" height="1010" rx="14" fill="url(#wall)" stroke="#211811" stroke-width="20" />
<path d="M0 140h770M0 292h770M0 444h770M0 596h770M0 748h770M0 900h770" stroke="#2e241b" stroke-width="12" opacity="0.72" />
<path d="M132 0v1010M330 0v1010M536 0v1010" stroke="#63513e" stroke-width="10" opacity="0.48" />
<rect x="250" y="504" width="270" height="168" fill="#1f1713" stroke="#0e0907" stroke-width="12" />
<path d="M250 504l135-84l135 84" fill="#734a24" stroke="#21160e" stroke-width="12" />
<path d="M336 672v-100h98v100" fill="#0d0907" />
<circle cx="120" cy="220" r="72" fill="url(#fire)" opacity="0.74" />
<circle cx="645" cy="720" r="78" fill="url(#fire)" opacity="0.7" />
</g>
<g transform="translate(620 865)">
<rect x="0" y="0" width="260" height="140" rx="10" fill="#5f4a35" stroke="#251d16" stroke-width="10" />
<path d="M-20 18L130-74l152 92Z" fill="#98622d" stroke="#2b1f14" stroke-width="10" />
<path d="M78 140v-62h60v62" fill="#201713" />
</g>
<g transform="translate(1265 1640)">
<rect x="0" y="0" width="230" height="126" rx="10" fill="#5d4935" stroke="#251d16" stroke-width="9" />
<path d="M-18 14L115-62l134 76Z" fill="#8c5a2a" stroke="#2b1f14" stroke-width="9" />
<path d="M72 126v-54h54v54" fill="#201713" />
</g>
<path d="M1880 680c210 40 456 28 752-38" fill="none" stroke="#15100d" stroke-width="20" opacity="0.35" />
<path d="M1840 1270c214 48 482 34 826-42" fill="none" stroke="#15100d" stroke-width="18" opacity="0.32" />
<path d="M160 1510c260 58 550 58 870 0" fill="none" stroke="#d8b15f" stroke-width="10" opacity="0.16" />
</g>
<rect width="2800" height="2200" fill="#07090c" opacity="0.18" />
<path d="M1860 460c-160 240-126 536 102 888" fill="none" stroke="#ffb15f" stroke-width="12" opacity="0.12" />
<path d="M1780 470c-102 228-76 470 78 724" fill="none" stroke="#ffe0a0" stroke-width="8" opacity="0.08" />
</svg>

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -11,6 +11,10 @@ import {
ninthBattleMap, ninthBattleMap,
ninthBattleUnits, ninthBattleUnits,
ninthBattleVictoryPages, ninthBattleVictoryPages,
tenthBattleBonds,
tenthBattleMap,
tenthBattleUnits,
tenthBattleVictoryPages,
fifthBattleBonds, fifthBattleBonds,
fifthBattleMap, fifthBattleMap,
fifthBattleUnits, fifthBattleUnits,
@@ -50,7 +54,8 @@ export type BattleScenarioId =
| 'sixth-battle-jieqiao-relief' | 'sixth-battle-jieqiao-relief'
| 'seventh-battle-xuzhou-rescue' | 'seventh-battle-xuzhou-rescue'
| 'eighth-battle-xiaopei-supply-road' | 'eighth-battle-xiaopei-supply-road'
| 'ninth-battle-xuzhou-gate-night-raid'; | 'ninth-battle-xuzhou-gate-night-raid'
| 'tenth-battle-xuzhou-breakout';
export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory'; export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory';
@@ -566,6 +571,59 @@ export const ninthBattleScenario: BattleScenarioDefinition = {
nextCampScene: 'CampScene' nextCampScene: 'CampScene'
}; };
export const tenthBattleScenario: BattleScenarioDefinition = {
id: 'tenth-battle-xuzhou-breakout',
title: '서주 탈출전',
victoryConditionLabel: '송헌 격파',
defeatConditionLabel: '유비 퇴각',
openingObjectiveLines: [
'서주는 여포에게 넘어갔고, 유비군은 성 밖으로 빠져나갈 길을 열어야 합니다. 송헌을 격파하면 추격대의 지휘가 무너집니다.',
'여포군 기병은 길을 따라 빠르게 압박하고, 성내 배반병은 숲과 마을 근처에서 퇴로를 끊으려 합니다.',
'서쪽 진영과 마을을 확보한 채 20턴 안에 돌파하면 조조 의탁으로 이어질 병력과 보급을 더 많이 보존합니다.'
],
map: tenthBattleMap,
units: tenthBattleUnits,
bonds: tenthBattleBonds,
mapTextureKey: 'battle-map-tenth',
leaderUnitId: 'xuzhou-escape-leader-song-xian',
quickVictoryTurnLimit: 20,
baseVictoryGold: 1240,
objectives: [
{
id: 'leader',
kind: 'defeat-leader',
label: '송헌 격파',
rewardGold: 760,
unitId: 'xuzhou-escape-leader-song-xian'
},
{
id: 'liu-bei',
kind: 'keep-unit-alive',
label: '유비 생존',
rewardGold: 300,
unitId: 'liu-bei'
},
{
id: 'village',
kind: 'secure-terrain',
label: '피난 마을 확보',
rewardGold: 420,
terrain: 'village'
},
{
id: 'quick',
kind: 'quick-victory',
label: '20턴 이내 돌파',
rewardGold: 340,
maxTurn: 20
}
],
defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }],
itemRewards: ['콩 4', '상처약 2', '탁주 1', '서주 피난민 보호 +1'],
victoryPages: tenthBattleVictoryPages,
nextCampScene: 'CampScene'
};
export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id; export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id;
export const battleScenarios: Record<BattleScenarioId, BattleScenarioDefinition> = { export const battleScenarios: Record<BattleScenarioId, BattleScenarioDefinition> = {
@@ -577,7 +635,8 @@ export const battleScenarios: Record<BattleScenarioId, BattleScenarioDefinition>
'sixth-battle-jieqiao-relief': sixthBattleScenario, 'sixth-battle-jieqiao-relief': sixthBattleScenario,
'seventh-battle-xuzhou-rescue': seventhBattleScenario, 'seventh-battle-xuzhou-rescue': seventhBattleScenario,
'eighth-battle-xiaopei-supply-road': eighthBattleScenario, 'eighth-battle-xiaopei-supply-road': eighthBattleScenario,
'ninth-battle-xuzhou-gate-night-raid': ninthBattleScenario 'ninth-battle-xuzhou-gate-night-raid': ninthBattleScenario,
'tenth-battle-xuzhou-breakout': tenthBattleScenario
}; };
export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId]; export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId];

View File

@@ -8,6 +8,7 @@ import {
secondBattleScenario, secondBattleScenario,
seventhBattleScenario, seventhBattleScenario,
sixthBattleScenario, sixthBattleScenario,
tenthBattleScenario,
thirdBattleScenario, thirdBattleScenario,
type BattleScenarioId type BattleScenarioId
} from './battles'; } from './battles';
@@ -27,6 +28,8 @@ import {
seventhBattleVictoryPages, seventhBattleVictoryPages,
sixthBattleIntroPages, sixthBattleIntroPages,
sixthBattleVictoryPages, sixthBattleVictoryPages,
tenthBattleIntroPages,
tenthBattleVictoryPages,
thirdBattleIntroPages, thirdBattleIntroPages,
thirdBattleVictoryPages, thirdBattleVictoryPages,
type StoryPage type StoryPage
@@ -127,12 +130,22 @@ const sortieFlows: Record<string, SortieFlow> = {
}, },
[ninthBattleScenario.id]: { [ninthBattleScenario.id]: {
afterBattleId: ninthBattleScenario.id, afterBattleId: ninthBattleScenario.id,
eyebrow: '다음 전장',
title: tenthBattleScenario.title,
description: '장비의 성급함과 여포의 야심이 맞물려 서주는 무너집니다. 성을 되찾기보다 포위망을 뚫고 다음 길을 찾는 전투가 됩니다.',
rewardHint: `예상 보상: ${tenthBattleScenario.title} 개방`,
nextBattleId: tenthBattleScenario.id,
campaignStep: 'tenth-battle',
pages: [...ninthBattleVictoryPages, ...tenthBattleIntroPages]
},
[tenthBattleScenario.id]: {
afterBattleId: tenthBattleScenario.id,
eyebrow: '다음 장 준비', eyebrow: '다음 장 준비',
title: '서주 상실의 전조', title: '조조 의탁의 길',
description: '성문은 지켰지만 여포와 서주 내부의 불씨는 더 커졌습니다. 다음 흐름은 장비의 실책과 서주 상실로 이어집니다.', description: '서주를 잃은 유비군은 살아남은 병력과 백성을 추슬러 허도로 향할 길을 찾습니다. 다음 흐름은 조조 의탁과 그 안의 긴장으로 이어집니다.',
rewardHint: '다음 장: 장비의 실책과 서주 상실 준비 중', rewardHint: '다음 장: 조조 의탁 준비 중',
pages: ninthBattleVictoryPages, pages: tenthBattleVictoryPages,
unavailableNotice: '서주 상실 전투는 다음 작업에서 이어집니다. 군영에서 성장과 출전 구성을 정비하십시오.' unavailableNotice: '조조 의탁 장은 다음 작업에서 이어집니다. 군영에서 성장과 출전 구성을 정비하십시오.'
} }
}; };

View File

@@ -649,6 +649,61 @@ export const ninthBattleVictoryPages: StoryPage[] = [
} }
]; ];
export const tenthBattleIntroPages: StoryPage[] = [
{
id: 'tenth-zhang-fei-fault',
bgm: 'story-dark',
chapter: '술잔이 부른 균열',
background: 'story-liu-bei',
speaker: '장비',
portrait: 'zhangFei',
text: '조표의 잔당을 다그치던 장비는 분노를 이기지 못했다. 말 한마디, 술 한잔, 그리고 성 안의 오래된 원한이 한밤중에 다시 타올랐다.'
},
{
id: 'tenth-lu-bu-takes-xuzhou',
bgm: 'story-dark',
chapter: '빼앗긴 서주',
background: 'story-militia',
text: '여포는 성문이 흔들린 틈을 놓치지 않았다. 서주 곳곳의 수비대가 갈라졌고, 유비가 돌아왔을 때 성루의 깃발은 이미 바뀌어 있었다.'
},
{
id: 'tenth-brothers-regroup',
bgm: 'militia-theme',
chapter: '무너진 성 앞에서',
background: 'story-three-heroes',
speaker: '관우',
portrait: 'guanYu',
text: '형님, 지금 성을 되찾으려 들면 백성과 군졸을 모두 잃습니다. 익덕을 꾸짖는 일은 살아나간 뒤에 해도 늦지 않습니다.'
},
{
id: 'tenth-xuzhou-escape',
bgm: 'battle-prep',
chapter: '서주 탈출',
background: 'story-sortie',
speaker: '유비',
portrait: 'liuBei',
text: '서주는 오늘 내 손에서 떠났다. 그러나 백성과 형제와 뜻까지 잃을 수는 없다. 포위망을 뚫고 다음 길을 찾자.'
}
];
export const tenthBattleVictoryPages: StoryPage[] = [
{
id: 'tenth-victory-breakout',
bgm: 'militia-theme',
chapter: '잃고도 남은 것',
background: 'story-militia',
text: '유비군은 여포군 선봉을 뚫고 서주 외곽으로 물러났다. 성은 빼앗겼으나, 세 형제와 새로 모인 장수들의 전열은 아직 흩어지지 않았다.'
},
{
id: 'tenth-cao-cao-shadow',
bgm: 'story-dark',
chapter: '허도로 향하는 그림자',
background: 'story-liu-bei',
speaker: '간옹',
text: '현덕, 이제 서주 안에서 버티는 길은 막혔네. 조조의 휘하로 들어가는 일은 마음에 걸리지만, 살아남아 다시 뜻을 세우려면 그 길을 지나야 할 걸세.'
}
];
export const firstBattleMap: BattleMap = { export const firstBattleMap: BattleMap = {
width: 20, width: 20,
height: 18, height: 18,
@@ -848,6 +903,12 @@ export const ninthBattleMap: BattleMap = {
terrain: createNinthBattleTerrain() terrain: createNinthBattleTerrain()
}; };
export const tenthBattleMap: BattleMap = {
width: 28,
height: 22,
terrain: createTenthBattleTerrain()
};
export const firstBattleUnits: UnitData[] = [ export const firstBattleUnits: UnitData[] = [
{ {
id: 'liu-bei', id: 'liu-bei',
@@ -3201,6 +3262,251 @@ export const ninthBattleUnits: UnitData[] = [
} }
]; ];
const tenthBattleAllyPositions: Record<string, { x: number; y: number }> = {
'liu-bei': { x: 3, y: 14 },
'guan-yu': { x: 4, y: 15 },
'zhang-fei': { x: 3, y: 16 },
'jian-yong': { x: 5, y: 17 },
'mi-zhu': { x: 4, y: 18 }
};
export const tenthBattleUnits: UnitData[] = [
...[...firstBattleUnits.filter((unit) => unit.faction === 'ally'), ...xuzhouRecruitUnits].map((unit) =>
placeScenarioUnit(unit, tenthBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })
),
{
id: 'xuzhou-escape-cavalry-a',
name: '여포군 추격기병',
faction: 'enemy',
className: '여포군 기병',
classKey: 'cavalry',
level: 11,
exp: 64,
hp: 52,
maxHp: 52,
attack: 18,
move: 5,
stats: { might: 91, intelligence: 52, leadership: 70, agility: 92, luck: 58 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 42 },
armor: { itemId: 'rebel-vest', level: 2, exp: 20 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 21,
y: 5
},
{
id: 'xuzhou-escape-cavalry-b',
name: '여포군 추격기병',
faction: 'enemy',
className: '여포군 기병',
classKey: 'cavalry',
level: 11,
exp: 66,
hp: 53,
maxHp: 53,
attack: 18,
move: 5,
stats: { might: 92, intelligence: 52, leadership: 70, agility: 93, luck: 58 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 44 },
armor: { itemId: 'rebel-vest', level: 2, exp: 20 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 23,
y: 9
},
{
id: 'xuzhou-escape-cavalry-c',
name: '여포군 추격기병',
faction: 'enemy',
className: '여포군 기병',
classKey: 'cavalry',
level: 11,
exp: 66,
hp: 53,
maxHp: 53,
attack: 18,
move: 5,
stats: { might: 92, intelligence: 53, leadership: 71, agility: 93, luck: 59 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 46 },
armor: { itemId: 'rebel-vest', level: 2, exp: 20 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 22,
y: 16
},
{
id: 'xuzhou-escape-guard-a',
name: '서주 점거병',
faction: 'enemy',
className: '여포군 보병',
classKey: 'yellowTurban',
level: 10,
exp: 62,
hp: 47,
maxHp: 47,
attack: 15,
move: 3,
stats: { might: 82, intelligence: 54, leadership: 69, agility: 63, luck: 55 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 36 },
armor: { itemId: 'lamellar-armor', level: 2, exp: 18 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 12,
y: 9
},
{
id: 'xuzhou-escape-guard-b',
name: '서주 점거병',
faction: 'enemy',
className: '여포군 보병',
classKey: 'yellowTurban',
level: 10,
exp: 62,
hp: 48,
maxHp: 48,
attack: 15,
move: 3,
stats: { might: 83, intelligence: 54, leadership: 70, agility: 64, luck: 55 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 38 },
armor: { itemId: 'lamellar-armor', level: 2, exp: 18 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 13,
y: 13
},
{
id: 'xuzhou-escape-guard-c',
name: '서주 점거병',
faction: 'enemy',
className: '여포군 보병',
classKey: 'yellowTurban',
level: 10,
exp: 64,
hp: 48,
maxHp: 48,
attack: 15,
move: 3,
stats: { might: 84, intelligence: 55, leadership: 70, agility: 64, luck: 56 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 40 },
armor: { itemId: 'lamellar-armor', level: 2, exp: 18 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 15,
y: 16
},
{
id: 'xuzhou-escape-archer-a',
name: '여포군 궁병',
faction: 'enemy',
className: '여포군 궁병',
classKey: 'archer',
level: 10,
exp: 62,
hp: 38,
maxHp: 38,
attack: 16,
move: 3,
stats: { might: 74, intelligence: 65, leadership: 63, agility: 74, luck: 56 },
equipment: {
weapon: { itemId: 'short-bow', level: 2, exp: 38 },
armor: { itemId: 'cloth-armor', level: 2, exp: 16 },
accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
},
x: 17,
y: 7
},
{
id: 'xuzhou-escape-archer-b',
name: '여포군 궁병',
faction: 'enemy',
className: '여포군 궁병',
classKey: 'archer',
level: 10,
exp: 64,
hp: 39,
maxHp: 39,
attack: 16,
move: 3,
stats: { might: 75, intelligence: 66, leadership: 64, agility: 75, luck: 56 },
equipment: {
weapon: { itemId: 'short-bow', level: 2, exp: 40 },
armor: { itemId: 'cloth-armor', level: 2, exp: 16 },
accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
},
x: 18,
y: 14
},
{
id: 'xuzhou-escape-raider-a',
name: '성내 배반병',
faction: 'enemy',
className: '성내 배반병',
classKey: 'bandit',
level: 10,
exp: 62,
hp: 43,
maxHp: 43,
attack: 16,
move: 4,
stats: { might: 84, intelligence: 52, leadership: 61, agility: 76, luck: 55 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 38 },
armor: { itemId: 'rebel-vest', level: 2, exp: 18 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 8,
y: 10
},
{
id: 'xuzhou-escape-raider-b',
name: '성내 배반병',
faction: 'enemy',
className: '성내 배반병',
classKey: 'bandit',
level: 10,
exp: 64,
hp: 44,
maxHp: 44,
attack: 16,
move: 4,
stats: { might: 85, intelligence: 52, leadership: 62, agility: 77, luck: 55 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 40 },
armor: { itemId: 'rebel-vest', level: 2, exp: 18 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 10,
y: 17
},
{
id: 'xuzhou-escape-leader-song-xian',
name: '송헌',
faction: 'enemy',
className: '여포군 선봉장',
classKey: 'rebelLeader',
level: 11,
exp: 70,
hp: 68,
maxHp: 68,
attack: 18,
move: 4,
stats: { might: 88, intelligence: 60, leadership: 84, agility: 72, luck: 58 },
equipment: {
weapon: { itemId: 'leader-axe', level: 2, exp: 42 },
armor: { itemId: 'lamellar-armor', level: 2, exp: 22 },
accessory: { itemId: 'war-manual', level: 1, exp: 0 }
},
x: 20,
y: 11
}
];
export const firstBattleBonds: BattleBond[] = [ export const firstBattleBonds: BattleBond[] = [
{ {
id: 'liu-bei__guan-yu', id: 'liu-bei__guan-yu',
@@ -3255,6 +3561,7 @@ export const sixthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBo
export const seventhBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); export const seventhBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
export const eighthBattleBonds: BattleBond[] = [...firstBattleBonds, ...xuzhouRecruitBonds].map(cloneBattleBondForScenario); export const eighthBattleBonds: BattleBond[] = [...firstBattleBonds, ...xuzhouRecruitBonds].map(cloneBattleBondForScenario);
export const ninthBattleBonds: BattleBond[] = eighthBattleBonds.map(cloneBattleBondForScenario); export const ninthBattleBonds: BattleBond[] = eighthBattleBonds.map(cloneBattleBondForScenario);
export const tenthBattleBonds: BattleBond[] = ninthBattleBonds.map(cloneBattleBondForScenario);
function createEighthBattleTerrain(): TerrainType[][] { function createEighthBattleTerrain(): TerrainType[][] {
return Array.from({ length: 22 }, (_, y) => return Array.from({ length: 22 }, (_, y) =>
@@ -3317,6 +3624,53 @@ function createNinthBattleTerrain(): TerrainType[][] {
); );
} }
function createTenthBattleTerrain(): TerrainType[][] {
return Array.from({ length: 22 }, (_, y) =>
Array.from({ length: 28 }, (_, x): TerrainType => {
if ((x <= 4 && y >= 14 && y <= 20) || (x >= 5 && x <= 7 && y >= 17 && y <= 20)) {
return 'camp';
}
if ((x >= 18 && x <= 25 && y >= 5 && y <= 12) || (x >= 20 && x <= 26 && y >= 13 && y <= 17)) {
return 'fort';
}
if ((x >= 6 && x <= 8 && y >= 9 && y <= 11) || (x >= 13 && x <= 14 && y >= 16 && y <= 18)) {
return 'village';
}
if ((x === 15 || x === 16) && y >= 2 && y <= 21) {
return 'river';
}
if (
(y === 15 && x >= 2 && x <= 26) ||
(x === 6 && y >= 7 && y <= 20) ||
(x === 20 && y >= 4 && y <= 18) ||
(x + y === 28 && x >= 9 && x <= 20)
) {
return 'road';
}
if (
(x <= 5 && y <= 6) ||
(x >= 7 && x <= 12 && y >= 2 && y <= 7) ||
(x >= 2 && x <= 9 && y >= 8 && y <= 13) ||
(x >= 8 && x <= 13 && y >= 18 && y <= 21)
) {
return 'forest';
}
if (
(x >= 21 && y <= 4) ||
(x >= 24 && y >= 18) ||
(x >= 10 && x <= 13 && y >= 11 && y <= 14) ||
(x <= 3 && y >= 7 && y <= 10)
) {
return 'hill';
}
if ((x >= 25 && y >= 6 && y <= 10) || (x >= 18 && x <= 20 && y <= 2)) {
return 'cliff';
}
return 'plain';
})
);
}
function placeScenarioUnit(unit: UnitData, position: { x: number; y: number }): UnitData { function placeScenarioUnit(unit: UnitData, position: { x: number; y: number }): UnitData {
return { return {
...cloneUnitForScenario(unit), ...cloneUnitForScenario(unit),

View File

@@ -545,7 +545,18 @@ const enemyAiByUnitId: Record<string, EnemyAiBehavior> = {
'lubu-cavalry-a': 'aggressive', 'lubu-cavalry-a': 'aggressive',
'lubu-cavalry-b': 'aggressive', 'lubu-cavalry-b': 'aggressive',
'lubu-cavalry-c': 'aggressive', 'lubu-cavalry-c': 'aggressive',
'xuzhou-leader-cao-bao': 'guard' 'xuzhou-leader-cao-bao': 'guard',
'xuzhou-escape-cavalry-a': 'aggressive',
'xuzhou-escape-cavalry-b': 'aggressive',
'xuzhou-escape-cavalry-c': 'aggressive',
'xuzhou-escape-guard-a': 'guard',
'xuzhou-escape-guard-b': 'guard',
'xuzhou-escape-guard-c': 'guard',
'xuzhou-escape-archer-a': 'hold',
'xuzhou-escape-archer-b': 'hold',
'xuzhou-escape-raider-a': 'aggressive',
'xuzhou-escape-raider-b': 'aggressive',
'xuzhou-escape-leader-song-xian': 'guard'
}; };
const defaultEnemyAiByClass: Partial<Record<UnitClassKey, EnemyAiBehavior>> = { const defaultEnemyAiByClass: Partial<Record<UnitClassKey, EnemyAiBehavior>> = {

View File

@@ -7,6 +7,7 @@ import ninthBattleMapUrl from '../../assets/images/battle/ninth-battle-map.svg';
import secondBattleMapUrl from '../../assets/images/battle/second-battle-map.svg'; import secondBattleMapUrl from '../../assets/images/battle/second-battle-map.svg';
import seventhBattleMapUrl from '../../assets/images/battle/seventh-battle-map.svg'; import seventhBattleMapUrl from '../../assets/images/battle/seventh-battle-map.svg';
import sixthBattleMapUrl from '../../assets/images/battle/sixth-battle-map.svg'; import sixthBattleMapUrl from '../../assets/images/battle/sixth-battle-map.svg';
import tenthBattleMapUrl from '../../assets/images/battle/tenth-battle-map.svg';
import thirdBattleMapUrl from '../../assets/images/battle/third-battle-map.svg'; import thirdBattleMapUrl from '../../assets/images/battle/third-battle-map.svg';
import guanYuPortraitUrl from '../../assets/images/portraits/guan-yu.png'; import guanYuPortraitUrl from '../../assets/images/portraits/guan-yu.png';
import liuBeiPortraitUrl from '../../assets/images/portraits/liu-bei.png'; import liuBeiPortraitUrl from '../../assets/images/portraits/liu-bei.png';
@@ -76,6 +77,7 @@ export class BootScene extends Phaser.Scene {
this.load.image('battle-map-seventh', seventhBattleMapUrl); this.load.image('battle-map-seventh', seventhBattleMapUrl);
this.load.image('battle-map-eighth', eighthBattleMapUrl); this.load.image('battle-map-eighth', eighthBattleMapUrl);
this.load.image('battle-map-ninth', ninthBattleMapUrl); this.load.image('battle-map-ninth', ninthBattleMapUrl);
this.load.image('battle-map-tenth', tenthBattleMapUrl);
this.load.image('portrait-liu-bei', liuBeiPortraitUrl); this.load.image('portrait-liu-bei', liuBeiPortraitUrl);
this.load.image('portrait-guan-yu', guanYuPortraitUrl); this.load.image('portrait-guan-yu', guanYuPortraitUrl);
this.load.image('portrait-zhang-fei', zhangFeiPortraitUrl); this.load.image('portrait-zhang-fei', zhangFeiPortraitUrl);

View File

@@ -139,7 +139,8 @@ const campBattleIds = {
sixth: 'sixth-battle-jieqiao-relief', sixth: 'sixth-battle-jieqiao-relief',
seventh: 'seventh-battle-xuzhou-rescue', seventh: 'seventh-battle-xuzhou-rescue',
eighth: 'eighth-battle-xiaopei-supply-road', eighth: 'eighth-battle-xiaopei-supply-road',
ninth: 'ninth-battle-xuzhou-gate-night-raid' ninth: 'ninth-battle-xuzhou-gate-night-raid',
tenth: 'tenth-battle-xuzhou-breakout'
} as const; } as const;
const requiredSortieUnitIds = new Set(['liu-bei']); const requiredSortieUnitIds = new Set(['liu-bei']);
@@ -928,6 +929,87 @@ const campDialogues: CampDialogue[] = [
rewardExp: 7 rewardExp: 7
} }
] ]
},
{
id: 'liu-zhang-after-xuzhou-loss',
title: '잃은 성 앞에서',
availableAfterBattleIds: [campBattleIds.tenth],
unitIds: ['liu-bei', 'zhang-fei'],
bondId: 'liu-bei__zhang-fei',
rewardExp: 24,
lines: [
'장비: 형님, 서주를 잃은 죄는 모두 제게 있습니다. 제 목을 베어 군율을 세우십시오.',
'유비: 익덕의 목을 베면 서주가 돌아오느냐. 살아서 다시 지키는 것이 네 벌이다.',
'장비: 다시는 술과 분노가 창끝보다 먼저 나가지 않게 하겠습니다.'
],
choices: [
{
id: 'forgive-and-command',
label: '용서하되 군율을 맡긴다',
response: '장비는 꾸지람보다 무거운 신뢰를 받고, 다음 전장에서는 먼저 자신을 다스리겠다고 맹세했다.',
rewardExp: 11
},
{
id: 'make-amends-through-action',
label: '공으로 갚게 한다',
response: '장비는 서주를 잃은 분노를 다음 싸움의 방패로 삼겠다고 답했다.',
rewardExp: 9
}
]
},
{
id: 'liu-guan-after-xuzhou-loss',
title: '흩어지지 않는 전열',
availableAfterBattleIds: [campBattleIds.tenth],
unitIds: ['liu-bei', 'guan-yu'],
bondId: 'liu-bei__guan-yu',
rewardExp: 22,
lines: [
'관우: 성은 잃었으나 형님의 깃발 아래 모인 마음은 아직 남았습니다.',
'유비: 운장, 내가 의를 앞세워 사람을 들였고 그 의가 백성을 흔들었다.',
'관우: 의는 버릴 것이 아니라 더 단단히 세울 것입니다. 이번 패배가 그 기둥이 되게 하십시오.'
],
choices: [
{
id: 'hold-the-banner',
label: '깃발을 다시 세운다',
response: '관우는 흩어진 병사들이 볼 수 있도록 유비군의 깃발을 다시 높이 세웠다.',
rewardExp: 10
},
{
id: 'protect-the-people',
label: '피난민을 먼저 모은다',
response: '관우는 패전의 행군에서도 백성을 버리지 않는 것이 형님의 길이라며 고개를 끄덕였다.',
rewardExp: 9
}
]
},
{
id: 'liu-jian-yong-after-xuzhou-loss',
title: '허도로 가는 말',
availableAfterBattleIds: [campBattleIds.tenth],
unitIds: ['liu-bei', 'jian-yong'],
bondId: 'liu-bei__jian-yong',
rewardExp: 20,
lines: [
'간옹: 현덕, 조조에게 몸을 의탁하는 일은 달갑지 않겠지. 하지만 지금은 그 그늘을 지나야 다음 길이 열린다네.',
'유비: 조조의 그늘이 너무 짙어 내 뜻까지 가리면 어찌하오?',
'간옹: 그때를 대비해 사람과 말을 잃지 말아야지. 뜻도 살아 있는 사람에게 붙는 법이네.'
],
choices: [
{
id: 'send-envoys',
label: '먼저 사자를 보낸다',
response: '간옹은 허도로 향할 말을 고르고, 유비군이 굴복이 아닌 임시 의탁임을 전할 문장을 다듬었다.',
rewardExp: 9
},
{
id: 'hide-strength',
label: '병력 규모를 감춘다',
response: '간옹은 조조가 보는 숫자와 실제로 보존할 숫자를 나누어 생각해야 한다고 조언했다.',
rewardExp: 8
}
]
} }
]; ];
@@ -1006,7 +1088,13 @@ export class CampScene extends Phaser.Scene {
private ensureCurrentCampRecruitment() { private ensureCurrentCampRecruitment() {
const battleId = this.currentCampBattleId(); const battleId = this.currentCampBattleId();
if ((battleId !== campBattleIds.seventh && battleId !== campBattleIds.eighth && battleId !== campBattleIds.ninth) || !this.campaign) { if (
(battleId !== campBattleIds.seventh &&
battleId !== campBattleIds.eighth &&
battleId !== campBattleIds.ninth &&
battleId !== campBattleIds.tenth) ||
!this.campaign
) {
return; return;
} }
@@ -1026,6 +1114,9 @@ export class CampScene extends Phaser.Scene {
private currentCampTitle() { private currentCampTitle() {
const battleId = this.currentCampBattleId(); const battleId = this.currentCampBattleId();
if (battleId === campBattleIds.tenth) {
return '서주 상실 후 군영';
}
if (battleId === campBattleIds.ninth) { if (battleId === campBattleIds.ninth) {
return '서주 성문 군영'; return '서주 성문 군영';
} }

View File

@@ -9,6 +9,7 @@ import {
secondBattleScenario, secondBattleScenario,
seventhBattleScenario, seventhBattleScenario,
sixthBattleScenario, sixthBattleScenario,
tenthBattleScenario,
thirdBattleScenario thirdBattleScenario
} from '../data/battles'; } from '../data/battles';
import { hasCampaignSave, loadCampaignState, startNewCampaign } from '../state/campaignState'; import { hasCampaignSave, loadCampaignState, startNewCampaign } from '../state/campaignState';
@@ -317,7 +318,8 @@ export class TitleScene extends Phaser.Scene {
campaign.step === 'sixth-camp' || campaign.step === 'sixth-camp' ||
campaign.step === 'seventh-camp' || campaign.step === 'seventh-camp' ||
campaign.step === 'eighth-camp' || campaign.step === 'eighth-camp' ||
campaign.step === 'ninth-camp' campaign.step === 'ninth-camp' ||
campaign.step === 'tenth-camp'
) { ) {
this.scene.start('CampScene'); this.scene.start('CampScene');
return; return;
@@ -368,6 +370,11 @@ export class TitleScene extends Phaser.Scene {
return; return;
} }
if (campaign.step === 'tenth-battle') {
this.scene.start('BattleScene', { battleId: tenthBattleScenario.id });
return;
}
if (campaign.step === 'first-victory-story') { if (campaign.step === 'first-victory-story') {
this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' }); this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' });
return; return;

View File

@@ -57,7 +57,9 @@ export type CampaignStep =
| 'eighth-battle' | 'eighth-battle'
| 'eighth-camp' | 'eighth-camp'
| 'ninth-battle' | 'ninth-battle'
| 'ninth-camp'; | 'ninth-camp'
| 'tenth-battle'
| 'tenth-camp';
export type CampaignUnitProgressSnapshot = { export type CampaignUnitProgressSnapshot = {
unitId: string; unitId: string;
@@ -117,7 +119,8 @@ const campaignBattleSteps: Record<string, { victory: CampaignStep; retry: Campai
'sixth-battle-jieqiao-relief': { victory: 'sixth-camp', retry: 'sixth-battle' }, 'sixth-battle-jieqiao-relief': { victory: 'sixth-camp', retry: 'sixth-battle' },
'seventh-battle-xuzhou-rescue': { victory: 'seventh-camp', retry: 'seventh-battle' }, 'seventh-battle-xuzhou-rescue': { victory: 'seventh-camp', retry: 'seventh-battle' },
'eighth-battle-xiaopei-supply-road': { victory: 'eighth-camp', retry: 'eighth-battle' }, 'eighth-battle-xiaopei-supply-road': { victory: 'eighth-camp', retry: 'eighth-battle' },
'ninth-battle-xuzhou-gate-night-raid': { victory: 'ninth-camp', retry: 'ninth-battle' } 'ninth-battle-xuzhou-gate-night-raid': { victory: 'ninth-camp', retry: 'ninth-battle' },
'tenth-battle-xuzhou-breakout': { victory: 'tenth-camp', retry: 'tenth-battle' }
}; };
export type CampaignSaveSlotSummary = { export type CampaignSaveSlotSummary = {