Add northern campaign prep milestone

This commit is contained in:
2026-06-24 01:27:20 +09:00
parent 1c26583508
commit 20b6b511cd
7 changed files with 347 additions and 27 deletions

View File

@@ -191,6 +191,7 @@ type SortieRecommendation = {
type SortieRuleDefinition = {
maxUnits: number;
requiredUnitIds?: string[];
excludedUnitIds?: string[];
recommended: SortieRecommendation[];
note: string;
};
@@ -424,6 +425,14 @@ const campaignTimelineChapters: CampaignTimelineChapter[] = [
'fifty-fourth-battle-meng-huo-final-capture'
],
nextHints: ['왕업 선언', '번성 포위', '형주 위기', '이릉 화공', '남중 안정', '맹획 회유', '칠종칠금']
},
{
id: 'northern-campaign',
title: '북벌 준비',
period: '출사표를 향해',
description: '남중 평정으로 뒤를 안정시킨 뒤, 한중 창고와 출전 무장 편성을 정비해 첫 북벌을 준비하는 장입니다.',
battleIds: [],
nextHints: ['한중 창고 정비', '출전 무장 재편', '기산 출진로', '장기전 보급']
}
];
@@ -1143,6 +1152,24 @@ const sortieRulesByBattleId: Partial<Record<BattleScenarioId, SortieRuleDefiniti
}
};
const sortieRulesByCampaignStep: Partial<Record<CampaignStep, SortieRuleDefinition>> = {
'northern-campaign-prep-camp': {
maxUnits: 7,
requiredUnitIds: ['zhuge-liang'],
excludedUnitIds: ['liu-bei'],
recommended: [
{ unitId: 'zhuge-liang', reason: '북벌 준비 회의의 중심입니다. 한중 창고와 전군 편성을 직접 조율해야 합니다.' },
{ unitId: 'zhao-yun', reason: '기산 출진 전 조운의 기동대 운용과 우회로 정찰이 필요합니다.' },
{ unitId: 'huang-quan', reason: '한중과 익주의 군량 장부를 맞춰 장기전 보급선을 안정시킵니다.' },
{ unitId: 'ma-liang', reason: '문서와 민심을 정리해 북벌 명분과 후방 행정의 빈틈을 줄입니다.' },
{ unitId: 'wang-ping', reason: '산길과 진영 배치를 읽어 다음 북방 전장의 지형 선택을 돕습니다.' },
{ unitId: 'ma-dai', reason: '기병 정찰과 추격 차단을 맡아 출진로의 측면 위험을 줄입니다.' },
{ unitId: 'wei-yan', reason: '거친 돌파력을 어디까지 전방에 둘지 미리 판단해야 할 장수입니다.' }
],
note: '북벌 준비 회의는 전투가 아니지만 다음 장기전의 편성을 정하는 단계입니다. 제갈량을 중심으로 보급, 산길, 기동, 돌파 역할을 균형 있게 고르십시오.'
}
};
const campDialogues: CampDialogue[] = [
{
id: 'liu-guan-after-first-battle',
@@ -5682,6 +5709,90 @@ const campDialogues: CampDialogue[] = [
rewardExp: 22
}
]
},
{
id: 'northern-prep-zhuge-zhao-march',
title: '북쪽으로 놓는 첫 발',
availableAfterBattleIds: [campBattleIds.fiftyFourth],
availableDuringSteps: ['northern-campaign-prep-camp'],
unitIds: ['zhuge-liang', 'zhao-yun'],
bondId: 'zhao-yun__zhuge-liang',
rewardExp: 76,
lines: [
'조운: 남중의 길은 정리되었지만, 북쪽 산길은 적이 먼저 기다리는 길입니다.',
'제갈량: 그래서 빠른 창보다 먼저 돌아올 수 있는 길을 정해야 합니다. 자룡의 기동대는 승리를 넓히는 손이 아니라, 전군을 잃지 않게 묶는 끈이 되어야 합니다.',
'조운: 알겠습니다. 다음 출진에서는 먼저 달리는 것보다, 모두가 따라올 수 있는 길을 열겠습니다.'
],
choices: [
{
id: 'draw-return-routes',
label: '귀환로를 먼저 그린다',
response: '제갈량은 북방 지도 위에 귀환로를 먼저 긋고, 조운은 기병이 회수 신호를 받을 수 있는 고개와 들판을 표시했습니다.',
rewardExp: 25
},
{
id: 'assign-mobile-screen',
label: '기동 엄호대를 편성한다',
response: '조운은 빠른 장수들을 묶어 엄호 순서를 맞췄고, 제갈량은 그 부대가 본대와 너무 멀어지지 않도록 신호 체계를 정했습니다.',
rewardExp: 24
}
]
},
{
id: 'northern-prep-huang-ma-ledgers',
title: '군량과 명분',
availableAfterBattleIds: [campBattleIds.fiftyFourth],
availableDuringSteps: ['northern-campaign-prep-camp'],
unitIds: ['huang-quan', 'ma-liang'],
bondId: 'zhuge-liang__huang-quan_final-capture',
rewardExp: 70,
lines: [
'황권: 북벌은 군량이 먼저 무너지는 싸움이 될 수 있습니다. 익주와 한중의 장부를 따로 보면 길이 끊깁니다.',
'마량: 장부만으로도 부족합니다. 백성에게 왜 이 길을 여는지 말이 남아야 합니다.',
'황권: 그러면 저는 수량을 맞추고, 마량은 그 수량이 원망이 되지 않게 말을 맞추십시오.'
],
choices: [
{
id: 'balance-hanzhong-granary',
label: '한중 창고 장부를 맞춘다',
response: '황권은 한중 창고의 군량과 수레 수를 다시 계산했고, 마량은 징발 대신 보상 문서를 함께 붙였습니다.',
rewardExp: 23
},
{
id: 'write-campaign-proclamation',
label: '북벌 고시문을 쓴다',
response: '마량은 북벌의 뜻을 짧게 적었고, 황권은 그 글 아래에 각 고을이 감당할 수 있는 보급 한계를 명확히 남겼습니다.',
rewardExp: 24
}
]
},
{
id: 'northern-prep-wang-madai-ridges',
title: '산길과 말발굽',
availableAfterBattleIds: [campBattleIds.fiftyFourth],
availableDuringSteps: ['northern-campaign-prep-camp'],
unitIds: ['wang-ping', 'ma-dai'],
bondId: 'zhao-yun__ma-dai_final-capture',
rewardExp: 66,
lines: [
'왕평: 북쪽 산길은 지도보다 좁고, 좁은 길은 기병에게도 보병에게도 함정이 됩니다.',
'마대: 그렇다면 말이 달릴 길보다 멈출 곳을 먼저 익히겠습니다.',
'왕평: 산길을 아는 자가 앞을 보고, 말을 아는 자가 뒤를 닫으면 본대가 길을 잃지 않을 것입니다.'
],
choices: [
{
id: 'mark-ridge-halts',
label: '고개 정지선을 표시한다',
response: '왕평은 좁은 고개마다 정지선을 표시했고, 마대는 기병이 그 선에서 멈추는 짧은 훈련을 반복했습니다.',
rewardExp: 22
},
{
id: 'test-scout-rotation',
label: '정찰 교대 순서를 시험한다',
response: '마대는 기병 정찰의 교대 시간을 줄였고, 왕평은 산길마다 보병이 이어받을 위치를 정했습니다.',
rewardExp: 21
}
]
}
];
@@ -8106,6 +8217,68 @@ const campVisits: CampVisitDefinition[] = [
itemRewards: ['콩 16', '탁주 4']
}
]
},
{
id: 'northern-prep-hanzhong-granary',
title: '한중 창고 점검',
location: '한중 군량 창고',
availableAfterBattleIds: [campBattleIds.fiftyFourth],
availableDuringSteps: ['northern-campaign-prep-camp'],
bondId: 'zhuge-liang__huang-quan_final-capture',
description: '제갈량과 황권이 북벌의 첫 출진 전에 한중 창고, 수레, 보급 기한을 다시 점검합니다.',
lines: [
'황권: 남중 평정 장부가 끝났다고 곧장 북쪽으로 달리면 군량이 먼저 말라 버립니다.',
'제갈량: 북벌은 뜻만으로 움직이지 않습니다. 뜻을 오래 버티게 하는 창고가 있어야 합니다.',
'한중 창고에는 수레 표식과 군량 날짜가 새로 적힌 장부가 펼쳐졌습니다.'
],
choices: [
{
id: 'count-northern-wagons',
label: '수레와 군량을 다시 센다',
response: '황권은 수레 수와 군량 날짜를 맞췄고, 제갈량은 먼저 보낼 물자와 남길 물자를 구분했습니다.',
bondExp: 58,
gold: 2140,
itemRewards: ['콩 20', '상처약 12']
},
{
id: 'reserve-field-rations',
label: '예비 군량을 따로 둔다',
response: '제갈량은 전공을 서두르는 병사들에게 예비 군량의 의미를 설명했고, 황권은 대기 부대 몫을 따로 봉했습니다.',
bondExp: 56,
itemRewards: ['탁주 6', '상처약 16']
}
]
},
{
id: 'northern-prep-memorial-table',
title: '출사표 초안',
location: '승상부 서안',
availableAfterBattleIds: [campBattleIds.fiftyFourth],
availableDuringSteps: ['northern-campaign-prep-camp'],
bondId: 'zhao-yun__zhuge-liang',
description: '제갈량이 북쪽으로 나아갈 뜻을 정리하고, 조운이 그 뜻을 실제 출진 순서로 옮길 준비를 합니다.',
lines: [
'제갈량: 글은 군을 움직이지 못하지만, 군이 왜 움직이는지는 잊지 않게 합니다.',
'조운: 그 뜻이 분명하면 장수들도 무리한 공을 다투지 않을 것입니다.',
'서안 위에는 출사표의 초안과 첫 출진 편성표가 함께 놓였습니다.'
],
choices: [
{
id: 'draft-memorial-oath',
label: '출사표 초안을 다듬는다',
response: '제갈량은 군주의 은혜와 한실의 뜻을 다시 적었고, 조운은 그 글이 장수들의 출진 순서와 어긋나지 않게 살폈습니다.',
bondExp: 58,
itemRewards: ['북벌 고시문 1']
},
{
id: 'match-sortie-order',
label: '출진 순서를 맞춘다',
response: '조운은 선발대와 본대의 거리를 정했고, 제갈량은 무리한 돌파보다 오래 버티는 행군표를 먼저 승인했습니다.',
bondExp: 56,
gold: 1980,
itemRewards: ['콩 18']
}
]
}
];
@@ -8903,6 +9076,9 @@ export class CampScene extends Phaser.Scene {
if (this.campaign?.step === 'baidi-entrustment-camp') {
return '백제성 유탁 후 군영';
}
if (this.campaign?.step === 'northern-campaign-prep-camp') {
return '북벌 준비 군영';
}
if (battleId === campBattleIds.fiftyFourth) {
return '남중 평정 후 군영';
}
@@ -9522,10 +9698,12 @@ export class CampScene extends Phaser.Scene {
}
private nextSortieRule(scenario = this.nextSortieScenario()) {
if (!scenario) {
return defaultSortieRule;
if (scenario) {
return sortieRulesByBattleId[scenario.id] ?? defaultSortieRule;
}
return sortieRulesByBattleId[scenario.id] ?? defaultSortieRule;
const step = this.campaign?.step;
const flowStep = this.currentSortieFlow().campaignStep;
return (step && sortieRulesByCampaignStep[step]) || (flowStep && sortieRulesByCampaignStep[flowStep]) || defaultSortieRule;
}
private requiredSortieUnitIdsFor(scenario = this.nextSortieScenario()) {
@@ -9660,6 +9838,7 @@ export class CampScene extends Phaser.Scene {
const selectedIds = new Set(selectedUnits.map((unit) => unit.id));
const scenario = this.nextSortieScenario();
const rule = this.nextSortieRule(scenario);
const flow = this.currentSortieFlow();
const reserveFocus = this.reserveTrainingFocusDefinition();
const availableIds = new Set(allAllies.map((unit) => unit.id));
const recommended = rule.recommended.filter((entry) => availableIds.has(entry.unitId));
@@ -9738,7 +9917,7 @@ export class CampScene extends Phaser.Scene {
reserveTrainingExpPreview: reserveFocus.expGained,
reserveTrainingEquipmentPreview: reserveFocus.equipmentExpGained,
reserveTrainingBondPreview: reserveFocus.bondExpGained,
objectiveLine: scenario ? `${scenario.title} · ${scenario.victoryConditionLabel}` : '다음 전투 정보 없음',
objectiveLine: scenario ? `${scenario.title} · ${scenario.victoryConditionLabel}` : `${flow.title} · 군영 의정`,
formationSlots,
warnings
};
@@ -9874,7 +10053,14 @@ export class CampScene extends Phaser.Scene {
private sortieChecklist(): SortieChecklistItem[] {
const units = this.currentUnits().filter((unit) => unit.faction === 'ally');
const liuBei = units.find((unit) => unit.id === 'liu-bei');
const sortieAllies = this.sortieAllies();
const requiredUnitIds = [...this.requiredSortieUnitIdsFor()].filter((id) => sortieAllies.some((unit) => unit.id === id));
const requiredUnits = requiredUnitIds.map((id) => sortieAllies.find((unit) => unit.id === id)).filter(Boolean) as UnitData[];
const requiredLabel = requiredUnits.length > 0 && !requiredUnitIds.includes('liu-bei') ? '필수 무장 확인' : '유비 생존';
const requiredComplete = requiredUnits.length > 0 ? requiredUnits.every((unit) => unit.hp > 0) : true;
const requiredDetail = requiredUnits.length > 0
? requiredUnits.map((unit) => `${unit.name} ${unit.hp}/${unit.maxHp}`).join(', ')
: '필수 무장 없음';
const injured = units.filter((unit) => unit.hp < unit.maxHp);
const supplyCount = campSupplies.reduce((total, supply) => total + this.inventoryAmount(supply.label), 0);
const availableDialogues = this.availableCampDialogues();
@@ -9884,9 +10070,9 @@ export class CampScene extends Phaser.Scene {
const selected = this.selectedSortieUnits();
return [
{
label: '유비 생존',
complete: Boolean(liuBei && liuBei.hp > 0),
detail: liuBei ? `병력 ${liuBei.hp}/${liuBei.maxHp}` : '유비 없음'
label: requiredLabel,
complete: requiredComplete,
detail: requiredDetail
},
{
label: '부상 장수 확인',
@@ -9895,7 +10081,7 @@ export class CampScene extends Phaser.Scene {
},
{
label: '출전 구성',
complete: selected.some((unit) => unit.id === 'liu-bei') && selected.length > 0,
complete: requiredUnitIds.every((id) => selected.some((unit) => unit.id === id)) && selected.length > 0,
detail: selected.length > 0 ? selected.map((unit) => unit.name).join(', ') : '출전 무장 선택 필요'
},
{
@@ -9940,7 +10126,15 @@ export class CampScene extends Phaser.Scene {
private startVictoryStory() {
const flow = this.currentSortieFlow();
if (!this.ensureSortieSelectionSaved()) {
this.showCampNotice('출전할 무장을 선택하세요. 유비는 반드시 출전해야 합니다.');
const availableIds = new Set(this.sortieAllies().map((unit) => unit.id));
const requiredNames = [...this.requiredSortieUnitIdsFor()]
.filter((unitId) => availableIds.has(unitId))
.map((unitId) => this.unitName(unitId));
this.showCampNotice(
requiredNames.length > 0
? `출전할 무장을 선택하세요. ${requiredNames.join(', ')}는 반드시 포함되어야 합니다.`
: '출전할 무장을 선택하세요.'
);
return;
}
@@ -10256,7 +10450,14 @@ export class CampScene extends Phaser.Scene {
private activeTimelineChapterIndex(completedIds = this.completedBattleIds()) {
const latestBattleId = this.campaign?.latestBattleId ?? this.report?.battleId;
const nextBattleId = this.currentSortieFlow().nextBattleId;
const flow = this.currentSortieFlow();
const nextBattleId = flow.nextBattleId;
if (!nextBattleId && flow.campaignStep === 'northern-campaign-prep-camp') {
const northernIndex = campaignTimelineChapters.findIndex((chapter) => chapter.id === 'northern-campaign');
if (northernIndex >= 0) {
return northernIndex;
}
}
if (!nextBattleId && latestBattleId) {
const latestChapterIndex = campaignTimelineChapters.findIndex((chapter) =>
chapter.battleIds.includes(latestBattleId as BattleScenarioId)
@@ -11033,11 +11234,13 @@ export class CampScene extends Phaser.Scene {
private sortieAllies() {
const scenario = this.nextSortieScenario();
const rule = this.nextSortieRule(scenario);
const scenarioAllyIds = scenario
? new Set(scenario.units.filter((unit) => unit.faction === 'ally').map((unit) => unit.id))
: undefined;
const excludedUnitIds = new Set(rule.excludedUnitIds ?? []);
return this.currentUnits().filter(
(unit) => unit.faction === 'ally' && unit.hp > 0 && (!scenarioAllyIds || scenarioAllyIds.has(unit.id))
(unit) => unit.faction === 'ally' && unit.hp > 0 && !excludedUnitIds.has(unit.id) && (!scenarioAllyIds || scenarioAllyIds.has(unit.id))
);
}