diff --git a/docs/roadmap.md b/docs/roadmap.md
index 90e2bb5..93b88c9 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -34,12 +34,13 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of
- Twelfth battle Xiapi outer siege, putting Liu Bei under Cao Cao's command against Lu Bu while preserving Liu Bei's independent motive and camp tension
- Thirteenth battle Xiapi final clash, ending Lu Bu's arc and opening the tension that will lead toward Liu Bei breaking from Cao Cao
- Fourteenth battle Cao Cao break route, adding Sun Qian as a recruitable strategist and making expanded sortie selection matter before Liu Bei heads toward Yuan Shao
+- Fifteenth battle Yuan Shao refuge road, pushing Liu Bei through Cao Cao's pursuit into Yuan Shao's camp while preserving the six-officer sortie roster
- 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
-- Flow verification script from title through the fourteenth battle victory, recruit sortie selection, and camp save state
+- Flow verification script from title through the fifteenth battle victory, recruit sortie selection, and camp save state
## Next Steps
-1. Build the Yuan Shao refuge chapter, then move into Liu Biao refuge arcs
+1. Build the Liu Biao refuge chapter, then move toward Zhuge Liang's recruitment arc
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
4. Add more recruitable officers as the campaign moves toward Cao Cao, Yuan Shao, Liu Biao, and Zhuge Liang
diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs
index fe6e8e1..a62dda8 100644
--- a/scripts/verify-flow.mjs
+++ b/scripts/verify-flow.mjs
@@ -1428,6 +1428,84 @@ try {
throw new Error(`Expected fourteenth camp to expose Cao break dialogue set and preserve Sun Qian in roster: ${JSON.stringify(fourteenthCampState)}`);
}
+ await page.mouse.click(1120, 38);
+ await page.waitForTimeout(160);
+ const fourteenthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
+ if (
+ !fourteenthCampSortieState?.sortieVisible ||
+ !fourteenthCampSortieState.sortiePlan?.objectiveLine?.includes('원소 의탁로') ||
+ !fourteenthCampSortieState.sortieRoster?.some((unit) => unit.id === 'sun-qian' && unit.recruited)
+ ) {
+ throw new Error(`Expected fourteenth camp sortie prep to target Yuan Shao refuge road with expanded roster: ${JSON.stringify(fourteenthCampSortieState)}`);
+ }
+ assertSortieTacticalRoster(fourteenthCampSortieState, ['liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu', 'sun-qian']);
+ await page.screenshot({ path: 'dist/verification-yuan-refuge-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-yuan-refuge-story.png', fullPage: true });
+
+ for (let i = 0; i < 18; i += 1) {
+ const enteredFifteenthBattle = await page.evaluate(() => {
+ const state = window.__HEROS_DEBUG__?.battle();
+ return state?.scene === 'BattleScene' && state?.battleId === 'fifteenth-battle-yuan-refuge-road';
+ });
+ if (enteredFifteenthBattle) {
+ 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 === 'fifteenth-battle-yuan-refuge-road' && state?.battleOutcome === null && state?.phase === 'idle';
+ });
+ await page.screenshot({ path: 'dist/verification-fifteenth-battle.png', fullPage: true });
+
+ const fifteenthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
+ const fifteenthEnemies = fifteenthBattleState.units.filter((unit) => unit.faction === 'enemy');
+ const fifteenthAllies = fifteenthBattleState.units.filter((unit) => unit.faction === 'ally');
+ const fifteenthEnemyBehaviors = new Set(fifteenthEnemies.map((unit) => unit.ai));
+ if (
+ fifteenthBattleState.camera?.mapWidth !== 34 ||
+ fifteenthBattleState.camera?.mapHeight !== 24 ||
+ fifteenthBattleState.victoryConditionLabel !== '채양 격파' ||
+ fifteenthEnemies.length < 16 ||
+ !fifteenthEnemyBehaviors.has('aggressive') ||
+ !fifteenthEnemyBehaviors.has('guard') ||
+ !fifteenthEnemyBehaviors.has('hold') ||
+ !fifteenthEnemies.some((unit) => unit.id === 'yuan-refuge-leader-cai-yang') ||
+ !fifteenthAllies.some((unit) => unit.id === 'sun-qian')
+ ) {
+ throw new Error(`Expected fifteenth battle to use Yuan refuge map, Cai Yang objective, mixed AI, and expanded sortie: ${JSON.stringify(fifteenthBattleState)}`);
+ }
+
+ 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 fifteenthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
+ if (
+ fifteenthCampState?.campBattleId !== 'fifteenth-battle-yuan-refuge-road' ||
+ fifteenthCampState.campTitle !== '원소 의탁 후 군영' ||
+ fifteenthCampState.availableDialogueIds?.length !== 3 ||
+ !fifteenthCampState.availableDialogueIds.every((id) => id.endsWith('yuan-refuge')) ||
+ !fifteenthCampState.campaign?.roster?.some((unit) => unit.id === 'sun-qian')
+ ) {
+ throw new Error(`Expected fifteenth camp to expose Yuan refuge dialogue set and preserve expanded roster: ${JSON.stringify(fifteenthCampState)}`);
+ }
+
await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene'));
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
@@ -1439,7 +1517,7 @@ try {
return activeScenes.includes('CampScene');
});
- console.log(`Verified title-to-fourteenth-battle flow, recruit sortie selection, result states, and debug API at ${targetUrl}`);
+ console.log(`Verified title-to-fifteenth-battle flow, recruit sortie selection, result states, and debug API at ${targetUrl}`);
} finally {
await browser?.close();
if (serverProcess && !serverProcess.killed) {
diff --git a/src/assets/images/battle/fifteenth-battle-map.svg b/src/assets/images/battle/fifteenth-battle-map.svg
new file mode 100644
index 0000000..bc22bbf
--- /dev/null
+++ b/src/assets/images/battle/fifteenth-battle-map.svg
@@ -0,0 +1,61 @@
+
diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts
index 543603a..2081088 100644
--- a/src/game/data/battles.ts
+++ b/src/game/data/battles.ts
@@ -1,5 +1,9 @@
import {
firstBattleBonds,
+ fifteenthBattleBonds,
+ fifteenthBattleMap,
+ fifteenthBattleUnits,
+ fifteenthBattleVictoryPages,
fourteenthBattleBonds,
fourteenthBattleMap,
fourteenthBattleUnits,
@@ -75,7 +79,8 @@ export type BattleScenarioId =
| 'eleventh-battle-xudu-refuge-road'
| 'twelfth-battle-xiapi-outer-siege'
| 'thirteenth-battle-xiapi-final'
- | 'fourteenth-battle-cao-break';
+ | 'fourteenth-battle-cao-break'
+ | 'fifteenth-battle-yuan-refuge-road';
export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory';
@@ -856,6 +861,59 @@ export const fourteenthBattleScenario: BattleScenarioDefinition = {
nextCampScene: 'CampScene'
};
+export const fifteenthBattleScenario: BattleScenarioDefinition = {
+ id: 'fifteenth-battle-yuan-refuge-road',
+ title: '원소 의탁로',
+ victoryConditionLabel: '채양 격파',
+ defeatConditionLabel: '유비 퇴각',
+ openingObjectiveLines: [
+ '유비군은 조조의 추격을 피해 원소 진영으로 향합니다. 채양을 격파하면 북상로의 압박을 끊고 접선지로 나아갈 수 있습니다.',
+ '강줄기와 고갯길 사이로 추격 기병이 파고들고, 궁병은 접선지를 향한 길을 끊으려 합니다. 전열을 세우면서도 손건과 간옹의 책략 지원을 살려야 합니다.',
+ '원소 사자가 기다리는 북동쪽 접선지를 확보하고 24턴 안에 승리하면 다음 의탁 장을 더 유리하게 시작합니다.'
+ ],
+ map: fifteenthBattleMap,
+ units: fifteenthBattleUnits,
+ bonds: fifteenthBattleBonds,
+ mapTextureKey: 'battle-map-fifteenth',
+ leaderUnitId: 'yuan-refuge-leader-cai-yang',
+ quickVictoryTurnLimit: 24,
+ baseVictoryGold: 1760,
+ objectives: [
+ {
+ id: 'leader',
+ kind: 'defeat-leader',
+ label: '채양 격파',
+ rewardGold: 1120,
+ unitId: 'yuan-refuge-leader-cai-yang'
+ },
+ {
+ id: 'liu-bei',
+ kind: 'keep-unit-alive',
+ label: '유비 생존',
+ rewardGold: 400,
+ unitId: 'liu-bei'
+ },
+ {
+ id: 'rendezvous',
+ kind: 'secure-terrain',
+ label: '원소 사자 접선지 확보',
+ rewardGold: 640,
+ terrain: 'fort'
+ },
+ {
+ id: 'quick',
+ kind: 'quick-victory',
+ label: '24턴 이내 승리',
+ rewardGold: 500,
+ maxTurn: 24
+ }
+ ],
+ defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }],
+ itemRewards: ['콩 6', '상처약 3', '탁주 2', '원소 접선 문서 +1'],
+ victoryPages: fifteenthBattleVictoryPages,
+ nextCampScene: 'CampScene'
+};
+
export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id;
export const battleScenarios: Record = {
@@ -872,7 +930,8 @@ export const battleScenarios: Record
'eleventh-battle-xudu-refuge-road': eleventhBattleScenario,
'twelfth-battle-xiapi-outer-siege': twelfthBattleScenario,
'thirteenth-battle-xiapi-final': thirteenthBattleScenario,
- 'fourteenth-battle-cao-break': fourteenthBattleScenario
+ 'fourteenth-battle-cao-break': fourteenthBattleScenario,
+ 'fifteenth-battle-yuan-refuge-road': fifteenthBattleScenario
};
export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId];
diff --git a/src/game/data/campaignFlow.ts b/src/game/data/campaignFlow.ts
index 4c67fb1..7844d10 100644
--- a/src/game/data/campaignFlow.ts
+++ b/src/game/data/campaignFlow.ts
@@ -4,6 +4,7 @@ import {
eighthBattleScenario,
eleventhBattleScenario,
fifthBattleScenario,
+ fifteenthBattleScenario,
fourteenthBattleScenario,
fourthBattleScenario,
ninthBattleScenario,
@@ -23,6 +24,8 @@ import {
eleventhBattleVictoryPages,
fifthBattleIntroPages,
fifthBattleVictoryPages,
+ fifteenthBattleIntroPages,
+ fifteenthBattleVictoryPages,
fourteenthBattleIntroPages,
fourteenthBattleVictoryPages,
firstBattleVictoryPages,
@@ -193,13 +196,24 @@ const sortieFlows: Record = {
},
[fourteenthBattleScenario.id]: {
afterBattleId: fourteenthBattleScenario.id,
- eyebrow: '다음 장 준비',
- title: '원소 의탁의 길',
+ eyebrow: '다음 전장',
+ title: fifteenthBattleScenario.title,
description:
- '조조의 장막을 벗어난 유비군은 원소 진영으로 향할 수밖에 없습니다. 다음 흐름은 원소 의탁과 조조의 추격을 피해 북상하는 장으로 이어집니다.',
- rewardHint: '다음 장: 원소 의탁 준비 중',
- pages: fourteenthBattleVictoryPages,
- unavailableNotice: '원소 의탁 장은 다음 작업에서 이어집니다. 군영에서 성장, 대화, 출전 구성을 정비하십시오.'
+ '조조의 장막을 벗어난 유비군은 원소 진영으로 향해야 합니다. 그러나 북상로에는 채양의 추격대가 깔려 있어, 접선지까지 길을 열어야 합니다.',
+ rewardHint: `예상 보상: ${fifteenthBattleScenario.title} 개방 / 원소 접선`,
+ nextBattleId: fifteenthBattleScenario.id,
+ campaignStep: 'fifteenth-battle',
+ pages: [...fourteenthBattleVictoryPages, ...fifteenthBattleIntroPages]
+ },
+ [fifteenthBattleScenario.id]: {
+ afterBattleId: fifteenthBattleScenario.id,
+ eyebrow: '다음 장 준비',
+ title: '유표 의탁의 길',
+ description:
+ '원소 진영에 몸을 의탁했지만, 큰 세력의 틈은 오래 머물 곳이 되기 어렵습니다. 다음 흐름은 원소 진영을 떠나 유표에게 의탁하는 장으로 이어집니다.',
+ rewardHint: '다음 장: 유표 의탁 준비 중',
+ pages: fifteenthBattleVictoryPages,
+ unavailableNotice: '유표 의탁 장은 다음 작업에서 이어집니다. 군영에서 성장, 대화, 출전 구성을 정비하십시오.'
}
};
diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts
index 7769872..e15d314 100644
--- a/src/game/data/scenario.ts
+++ b/src/game/data/scenario.ts
@@ -941,6 +941,71 @@ export const fourteenthBattleVictoryPages: StoryPage[] = [
}
];
+export const fifteenthBattleIntroPages: StoryPage[] = [
+ {
+ id: 'fifteenth-yuan-envoy-rumor',
+ bgm: 'story-dark',
+ chapter: '북쪽의 초대',
+ background: 'story-militia',
+ speaker: '손건',
+ text: '원소 진영에서 사자가 내려왔습니다. 조조를 떠난 주공을 받아들일 뜻은 있으나, 북상로 곳곳에는 조조군 추격대가 깔려 있습니다.'
+ },
+ {
+ id: 'fifteenth-liu-bei-under-yuan-shadow',
+ bgm: 'battle-prep',
+ chapter: '원소 의탁',
+ background: 'story-liu-bei',
+ speaker: '유비',
+ portrait: 'liuBei',
+ text: '원소의 깃발 아래로 들어가는 일은 뜻을 접는 일이 아니다. 지금은 큰 세력의 틈에서 백성을 지킬 시간을 얻어야 한다.'
+ },
+ {
+ id: 'fifteenth-guan-yu-pursuit-road',
+ bgm: 'battle-prep',
+ chapter: '추격로',
+ background: 'story-three-heroes',
+ speaker: '관우',
+ portrait: 'guanYu',
+ text: '추격대가 강가와 고갯길을 나누어 막고 있습니다. 채양의 군세를 물리치고 접선지를 확보해야 원소 진영과 이어질 수 있습니다.'
+ },
+ {
+ id: 'fifteenth-yuan-road-sortie',
+ bgm: 'battle-prep',
+ chapter: '북상 전투',
+ background: 'story-sortie',
+ speaker: '장비',
+ portrait: 'zhangFei',
+ text: '쫓아오는 놈들이 조조의 명이라 떠들어도 결국 길을 막는 적일 뿐입니다. 형님, 이번엔 북쪽 길을 시원하게 열어 봅시다!'
+ }
+];
+
+export const fifteenthBattleVictoryPages: StoryPage[] = [
+ {
+ id: 'fifteenth-victory-pursuit-breaks',
+ bgm: 'militia-theme',
+ chapter: '추격을 끊다',
+ background: 'story-sortie',
+ text: '채양의 추격대가 흩어지자 북상로의 먼지가 잠시 가라앉았다. 유비군은 원소의 사자가 기다리는 접선지에 닿을 수 있었다.'
+ },
+ {
+ id: 'fifteenth-yuan-camp-opens',
+ bgm: 'story-dark',
+ chapter: '원소의 장막',
+ background: 'story-militia',
+ speaker: '간옹',
+ text: '원소의 진영은 넓고 군세는 크지만, 그만큼 말과 눈도 많습니다. 몸을 맡기되 마음까지 맡기지는 말아야 하겠지요.'
+ },
+ {
+ id: 'fifteenth-road-to-liu-biao',
+ bgm: 'battle-prep',
+ chapter: '다음 의탁의 그림자',
+ background: 'story-liu-bei',
+ speaker: '유비',
+ portrait: 'liuBei',
+ text: '큰 깃발 아래에서도 우리의 뜻은 작아져서는 안 된다. 원소의 군영에서 기회를 살피고, 다시 백성을 지킬 길을 찾아야 한다.'
+ }
+];
+
export const firstBattleMap: BattleMap = {
width: 20,
height: 18,
@@ -1170,6 +1235,12 @@ export const fourteenthBattleMap: BattleMap = {
terrain: createFourteenthBattleTerrain()
};
+export const fifteenthBattleMap: BattleMap = {
+ width: 34,
+ height: 24,
+ terrain: createFifteenthBattleTerrain()
+};
+
export const firstBattleUnits: UnitData[] = [
{
id: 'liu-bei',
@@ -5004,6 +5075,378 @@ export const fourteenthBattleUnits: UnitData[] = [
}
];
+const fifteenthBattleAllyPositions: Record = {
+ 'liu-bei': { x: 3, y: 18 },
+ 'guan-yu': { x: 4, y: 17 },
+ 'zhang-fei': { x: 4, y: 19 },
+ 'jian-yong': { x: 5, y: 18 },
+ 'mi-zhu': { x: 3, y: 20 },
+ 'sun-qian': { x: 5, y: 20 }
+};
+
+export const fifteenthBattleUnits: UnitData[] = [
+ ...[...firstBattleUnits.filter((unit) => unit.faction === 'ally'), ...xuzhouRecruitUnits, ...caoBreakRecruitUnits].map((unit) =>
+ placeScenarioUnit(unit, fifteenthBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })
+ ),
+ {
+ id: 'yuan-refuge-scout-a',
+ name: '조조군 척후',
+ faction: 'enemy',
+ className: '추격 척후',
+ classKey: 'bandit',
+ level: 15,
+ exp: 92,
+ hp: 54,
+ maxHp: 54,
+ attack: 20,
+ move: 4,
+ stats: { might: 90, intelligence: 62, leadership: 68, agility: 88, luck: 62 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 68 },
+ armor: { itemId: 'rebel-vest', level: 2, exp: 30 },
+ accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
+ },
+ x: 10,
+ y: 16
+ },
+ {
+ id: 'yuan-refuge-scout-b',
+ name: '조조군 척후',
+ faction: 'enemy',
+ className: '추격 척후',
+ classKey: 'bandit',
+ level: 15,
+ exp: 92,
+ hp: 54,
+ maxHp: 54,
+ attack: 20,
+ move: 4,
+ stats: { might: 90, intelligence: 62, leadership: 68, agility: 88, luck: 62 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 68 },
+ armor: { itemId: 'rebel-vest', level: 2, exp: 30 },
+ accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
+ },
+ x: 12,
+ y: 20
+ },
+ {
+ id: 'yuan-refuge-infantry-a',
+ name: '조조 보병',
+ faction: 'enemy',
+ className: '추격 보병',
+ classKey: 'yellowTurban',
+ level: 15,
+ exp: 94,
+ hp: 62,
+ maxHp: 62,
+ attack: 20,
+ move: 3,
+ stats: { might: 92, intelligence: 62, leadership: 80, agility: 70, luck: 61 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 70 },
+ armor: { itemId: 'lamellar-armor', level: 2, exp: 32 },
+ accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
+ },
+ x: 15,
+ y: 12
+ },
+ {
+ id: 'yuan-refuge-infantry-b',
+ name: '조조 보병',
+ faction: 'enemy',
+ className: '추격 보병',
+ classKey: 'yellowTurban',
+ level: 15,
+ exp: 94,
+ hp: 62,
+ maxHp: 62,
+ attack: 20,
+ move: 3,
+ stats: { might: 92, intelligence: 62, leadership: 80, agility: 70, luck: 61 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 70 },
+ armor: { itemId: 'lamellar-armor', level: 2, exp: 32 },
+ accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
+ },
+ x: 17,
+ y: 14
+ },
+ {
+ id: 'yuan-refuge-infantry-c',
+ name: '조조 보병',
+ faction: 'enemy',
+ className: '추격 보병',
+ classKey: 'yellowTurban',
+ level: 16,
+ exp: 96,
+ hp: 64,
+ maxHp: 64,
+ attack: 21,
+ move: 3,
+ stats: { might: 94, intelligence: 63, leadership: 82, agility: 71, luck: 62 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 72 },
+ armor: { itemId: 'lamellar-armor', level: 2, exp: 34 },
+ accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
+ },
+ x: 20,
+ y: 10
+ },
+ {
+ id: 'yuan-refuge-infantry-d',
+ name: '조조 보병',
+ faction: 'enemy',
+ className: '추격 보병',
+ classKey: 'yellowTurban',
+ level: 16,
+ exp: 96,
+ hp: 64,
+ maxHp: 64,
+ attack: 21,
+ move: 3,
+ stats: { might: 94, intelligence: 63, leadership: 82, agility: 71, luck: 62 },
+ equipment: {
+ weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 72 },
+ armor: { itemId: 'lamellar-armor', level: 2, exp: 34 },
+ accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
+ },
+ x: 23,
+ y: 15
+ },
+ {
+ id: 'yuan-refuge-archer-a',
+ name: '조조 궁병',
+ faction: 'enemy',
+ className: '추격 궁병',
+ classKey: 'archer',
+ level: 15,
+ exp: 94,
+ hp: 50,
+ maxHp: 50,
+ attack: 20,
+ move: 3,
+ stats: { might: 83, intelligence: 74, leadership: 72, agility: 84, luck: 62 },
+ equipment: {
+ weapon: { itemId: 'short-bow', level: 2, exp: 68 },
+ armor: { itemId: 'cloth-armor', level: 2, exp: 30 },
+ accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
+ },
+ x: 18,
+ y: 8
+ },
+ {
+ id: 'yuan-refuge-archer-b',
+ name: '조조 궁병',
+ faction: 'enemy',
+ className: '추격 궁병',
+ classKey: 'archer',
+ level: 15,
+ exp: 94,
+ hp: 50,
+ maxHp: 50,
+ attack: 20,
+ move: 3,
+ stats: { might: 83, intelligence: 74, leadership: 72, agility: 84, luck: 62 },
+ equipment: {
+ weapon: { itemId: 'short-bow', level: 2, exp: 68 },
+ armor: { itemId: 'cloth-armor', level: 2, exp: 30 },
+ accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
+ },
+ x: 23,
+ y: 8
+ },
+ {
+ id: 'yuan-refuge-archer-c',
+ name: '조조 궁병',
+ faction: 'enemy',
+ className: '추격 궁병',
+ classKey: 'archer',
+ level: 16,
+ exp: 96,
+ hp: 52,
+ maxHp: 52,
+ attack: 21,
+ move: 3,
+ stats: { might: 84, intelligence: 75, leadership: 73, agility: 85, luck: 63 },
+ equipment: {
+ weapon: { itemId: 'short-bow', level: 2, exp: 70 },
+ armor: { itemId: 'cloth-armor', level: 2, exp: 32 },
+ accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
+ },
+ x: 27,
+ y: 13
+ },
+ {
+ id: 'yuan-refuge-cavalry-a',
+ name: '조조 기병',
+ faction: 'enemy',
+ className: '추격 기병',
+ classKey: 'cavalry',
+ level: 16,
+ exp: 98,
+ hp: 68,
+ maxHp: 68,
+ attack: 23,
+ move: 5,
+ stats: { might: 98, intelligence: 58, leadership: 76, agility: 92, luck: 64 },
+ equipment: {
+ weapon: { itemId: 'iron-spear', level: 2, exp: 72 },
+ armor: { itemId: 'lamellar-armor', level: 2, exp: 34 },
+ accessory: { itemId: 'bravery-token', level: 1, exp: 0 }
+ },
+ x: 14,
+ y: 18
+ },
+ {
+ id: 'yuan-refuge-cavalry-b',
+ name: '조조 기병',
+ faction: 'enemy',
+ className: '추격 기병',
+ classKey: 'cavalry',
+ level: 16,
+ exp: 98,
+ hp: 68,
+ maxHp: 68,
+ attack: 23,
+ move: 5,
+ stats: { might: 98, intelligence: 58, leadership: 76, agility: 92, luck: 64 },
+ equipment: {
+ weapon: { itemId: 'iron-spear', level: 2, exp: 72 },
+ armor: { itemId: 'lamellar-armor', level: 2, exp: 34 },
+ accessory: { itemId: 'bravery-token', level: 1, exp: 0 }
+ },
+ x: 19,
+ y: 19
+ },
+ {
+ id: 'yuan-refuge-cavalry-c',
+ name: '조조 기병',
+ faction: 'enemy',
+ className: '추격 기병',
+ classKey: 'cavalry',
+ level: 16,
+ exp: 98,
+ hp: 68,
+ maxHp: 68,
+ attack: 23,
+ move: 5,
+ stats: { might: 98, intelligence: 58, leadership: 76, agility: 92, luck: 64 },
+ equipment: {
+ weapon: { itemId: 'iron-spear', level: 2, exp: 72 },
+ armor: { itemId: 'lamellar-armor', level: 2, exp: 34 },
+ accessory: { itemId: 'bravery-token', level: 1, exp: 0 }
+ },
+ x: 25,
+ y: 19
+ },
+ {
+ id: 'yuan-refuge-guard-a',
+ name: '북상로 수비대',
+ faction: 'enemy',
+ className: '관문 수비대',
+ classKey: 'yellowTurban',
+ level: 16,
+ exp: 98,
+ hp: 70,
+ maxHp: 70,
+ attack: 22,
+ move: 3,
+ stats: { might: 96, intelligence: 64, leadership: 84, agility: 70, luck: 62 },
+ equipment: {
+ weapon: { itemId: 'iron-spear', level: 2, exp: 74 },
+ armor: { itemId: 'lamellar-armor', level: 2, exp: 36 },
+ accessory: { itemId: 'bravery-token', level: 1, exp: 0 }
+ },
+ x: 27,
+ y: 9
+ },
+ {
+ id: 'yuan-refuge-guard-b',
+ name: '북상로 수비대',
+ faction: 'enemy',
+ className: '관문 수비대',
+ classKey: 'yellowTurban',
+ level: 16,
+ exp: 98,
+ hp: 70,
+ maxHp: 70,
+ attack: 22,
+ move: 3,
+ stats: { might: 96, intelligence: 64, leadership: 84, agility: 70, luck: 62 },
+ equipment: {
+ weapon: { itemId: 'iron-spear', level: 2, exp: 74 },
+ armor: { itemId: 'lamellar-armor', level: 2, exp: 36 },
+ accessory: { itemId: 'bravery-token', level: 1, exp: 0 }
+ },
+ x: 29,
+ y: 12
+ },
+ {
+ id: 'yuan-refuge-guard-c',
+ name: '북상로 수비대',
+ faction: 'enemy',
+ className: '관문 수비대',
+ classKey: 'yellowTurban',
+ level: 16,
+ exp: 98,
+ hp: 70,
+ maxHp: 70,
+ attack: 22,
+ move: 3,
+ stats: { might: 96, intelligence: 64, leadership: 84, agility: 70, luck: 62 },
+ equipment: {
+ weapon: { itemId: 'iron-spear', level: 2, exp: 74 },
+ armor: { itemId: 'lamellar-armor', level: 2, exp: 36 },
+ accessory: { itemId: 'bravery-token', level: 1, exp: 0 }
+ },
+ x: 30,
+ y: 15
+ },
+ {
+ id: 'yuan-refuge-strategist-a',
+ name: '조조군 참모',
+ faction: 'enemy',
+ className: '추격 참모',
+ classKey: 'archer',
+ level: 16,
+ exp: 98,
+ hp: 54,
+ maxHp: 54,
+ attack: 22,
+ move: 3,
+ stats: { might: 70, intelligence: 88, leadership: 78, agility: 76, luck: 66 },
+ equipment: {
+ weapon: { itemId: 'short-bow', level: 2, exp: 72 },
+ armor: { itemId: 'cloth-armor', level: 2, exp: 34 },
+ accessory: { itemId: 'war-manual', level: 1, exp: 0 }
+ },
+ x: 26,
+ y: 11
+ },
+ {
+ id: 'yuan-refuge-leader-cai-yang',
+ name: '채양',
+ faction: 'enemy',
+ className: '조조군 추격장',
+ classKey: 'rebelLeader',
+ level: 18,
+ exp: 12,
+ hp: 92,
+ maxHp: 92,
+ attack: 26,
+ move: 4,
+ stats: { might: 104, intelligence: 58, leadership: 90, agility: 82, luck: 64 },
+ equipment: {
+ weapon: { itemId: 'iron-spear', level: 3, exp: 18 },
+ armor: { itemId: 'reinforced-lamellar', level: 2, exp: 42 },
+ accessory: { itemId: 'bravery-token', level: 1, exp: 0 }
+ },
+ x: 31,
+ y: 12
+ }
+];
+
export const firstBattleBonds: BattleBond[] = [
{
id: 'liu-bei__guan-yu',
@@ -5084,6 +5527,7 @@ export const thirteenthBattleBonds: BattleBond[] = twelfthBattleBonds.map(cloneB
export const fourteenthBattleBonds: BattleBond[] = [...thirteenthBattleBonds, ...caoBreakRecruitBonds].map(
cloneBattleBondForScenario
);
+export const fifteenthBattleBonds: BattleBond[] = fourteenthBattleBonds.map(cloneBattleBondForScenario);
function createEighthBattleTerrain(): TerrainType[][] {
return Array.from({ length: 22 }, (_, y) =>
@@ -5388,6 +5832,58 @@ function createFourteenthBattleTerrain(): TerrainType[][] {
);
}
+function createFifteenthBattleTerrain(): TerrainType[][] {
+ return Array.from({ length: 24 }, (_, y) =>
+ Array.from({ length: 34 }, (_, x): TerrainType => {
+ if (x <= 2 && y >= 17 && y <= 21) {
+ return 'camp';
+ }
+ if ((x >= 27 && x <= 32 && y >= 8 && y <= 15) || (x >= 30 && x <= 33 && y >= 5 && y <= 7)) {
+ return 'fort';
+ }
+ if ((x >= 9 && x <= 11 && y >= 15 && y <= 17) || (x >= 20 && x <= 22 && y >= 7 && y <= 9)) {
+ return 'village';
+ }
+ if ((x === 14 || x === 15) && y >= 1 && y <= 22) {
+ return 'river';
+ }
+ if ((x === 24 || x === 25) && y >= 2 && y <= 20) {
+ return 'river';
+ }
+ if (
+ (y === 18 && x >= 2 && x <= 17) ||
+ (y === 14 && x >= 13 && x <= 28) ||
+ (y === 10 && x >= 21 && x <= 32) ||
+ (x === 29 && y >= 8 && y <= 17)
+ ) {
+ return 'road';
+ }
+ if ((x >= 5 && x <= 17 && y === 23 - x) || (x >= 12 && x <= 28 && y === x - 7)) {
+ return 'road';
+ }
+ if (
+ (x <= 8 && y <= 7) ||
+ (x >= 4 && x <= 11 && y >= 9 && y <= 13) ||
+ (x >= 2 && x <= 8 && y >= 14 && y <= 16) ||
+ (x >= 27 && y >= 18 && y <= 22)
+ ) {
+ return 'forest';
+ }
+ if (
+ (x >= 6 && x <= 12 && y >= 2 && y <= 5) ||
+ (x >= 18 && x <= 22 && y >= 18 && y <= 22) ||
+ (x >= 29 && x <= 33 && y >= 1 && y <= 4)
+ ) {
+ return 'hill';
+ }
+ if ((x >= 32 && y <= 8) || (x >= 32 && y >= 16) || (x <= 1 && y <= 4)) {
+ 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 df8bff9..e0c4450 100644
--- a/src/game/scenes/BattleScene.ts
+++ b/src/game/scenes/BattleScene.ts
@@ -103,7 +103,24 @@ const unitTexture: Record = {
'cao-break-cavalry-c': 'unit-rebel-cavalry',
'cao-break-guard-a': 'unit-rebel',
'cao-break-guard-b': 'unit-rebel',
- 'cao-break-leader-che-zhou': 'unit-rebel-leader'
+ 'cao-break-leader-che-zhou': 'unit-rebel-leader',
+ 'yuan-refuge-scout-a': 'unit-rebel',
+ 'yuan-refuge-scout-b': 'unit-rebel',
+ 'yuan-refuge-infantry-a': 'unit-rebel',
+ 'yuan-refuge-infantry-b': 'unit-rebel',
+ 'yuan-refuge-infantry-c': 'unit-rebel',
+ 'yuan-refuge-infantry-d': 'unit-rebel',
+ 'yuan-refuge-archer-a': 'unit-rebel-archer',
+ 'yuan-refuge-archer-b': 'unit-rebel-archer',
+ 'yuan-refuge-archer-c': 'unit-rebel-archer',
+ 'yuan-refuge-cavalry-a': 'unit-rebel-cavalry',
+ 'yuan-refuge-cavalry-b': 'unit-rebel-cavalry',
+ 'yuan-refuge-cavalry-c': 'unit-rebel-cavalry',
+ 'yuan-refuge-guard-a': 'unit-rebel',
+ 'yuan-refuge-guard-b': 'unit-rebel',
+ 'yuan-refuge-guard-c': 'unit-rebel',
+ 'yuan-refuge-strategist-a': 'unit-rebel-archer',
+ 'yuan-refuge-leader-cai-yang': 'unit-rebel-leader'
};
const unitTextureByClass: Partial> = {
@@ -655,7 +672,24 @@ const enemyAiByUnitId: Record = {
'cao-break-cavalry-c': 'aggressive',
'cao-break-guard-a': 'guard',
'cao-break-guard-b': 'guard',
- 'cao-break-leader-che-zhou': 'guard'
+ 'cao-break-leader-che-zhou': 'guard',
+ 'yuan-refuge-scout-a': 'aggressive',
+ 'yuan-refuge-scout-b': 'aggressive',
+ 'yuan-refuge-infantry-a': 'guard',
+ 'yuan-refuge-infantry-b': 'guard',
+ 'yuan-refuge-infantry-c': 'guard',
+ 'yuan-refuge-infantry-d': 'guard',
+ 'yuan-refuge-archer-a': 'hold',
+ 'yuan-refuge-archer-b': 'hold',
+ 'yuan-refuge-archer-c': 'hold',
+ 'yuan-refuge-cavalry-a': 'aggressive',
+ 'yuan-refuge-cavalry-b': 'aggressive',
+ 'yuan-refuge-cavalry-c': 'aggressive',
+ 'yuan-refuge-guard-a': 'guard',
+ 'yuan-refuge-guard-b': 'guard',
+ 'yuan-refuge-guard-c': 'guard',
+ 'yuan-refuge-strategist-a': 'hold',
+ 'yuan-refuge-leader-cai-yang': 'guard'
};
const defaultEnemyAiByClass: Partial> = {
diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts
index d3f205e..172de23 100644
--- a/src/game/scenes/BootScene.ts
+++ b/src/game/scenes/BootScene.ts
@@ -2,6 +2,7 @@ import Phaser from 'phaser';
import eighthBattleMapUrl from '../../assets/images/battle/eighth-battle-map.svg';
import eleventhBattleMapUrl from '../../assets/images/battle/eleventh-battle-map.svg';
import fifthBattleMapUrl from '../../assets/images/battle/fifth-battle-map.svg';
+import fifteenthBattleMapUrl from '../../assets/images/battle/fifteenth-battle-map.svg';
import firstBattleMapUrl from '../../assets/images/battle/first-battle-map.png';
import fourteenthBattleMapUrl from '../../assets/images/battle/fourteenth-battle-map.svg';
import fourthBattleMapUrl from '../../assets/images/battle/fourth-battle-map.svg';
@@ -86,6 +87,7 @@ export class BootScene extends Phaser.Scene {
this.load.image('battle-map-twelfth', twelfthBattleMapUrl);
this.load.image('battle-map-thirteenth', thirteenthBattleMapUrl);
this.load.image('battle-map-fourteenth', fourteenthBattleMapUrl);
+ this.load.image('battle-map-fifteenth', fifteenthBattleMapUrl);
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 a968098..2a5fafc 100644
--- a/src/game/scenes/CampScene.ts
+++ b/src/game/scenes/CampScene.ts
@@ -193,7 +193,8 @@ const campBattleIds = {
eleventh: 'eleventh-battle-xudu-refuge-road',
twelfth: 'twelfth-battle-xiapi-outer-siege',
thirteenth: 'thirteenth-battle-xiapi-final',
- fourteenth: 'fourteenth-battle-cao-break'
+ fourteenth: 'fourteenth-battle-cao-break',
+ fifteenth: 'fifteenth-battle-yuan-refuge-road'
} as const;
const requiredSortieUnitIds = new Set(['liu-bei']);
@@ -337,6 +338,17 @@ const sortieRulesByBattleId: Partial