feat: carry camp scouting into battle deployment

This commit is contained in:
2026-07-27 07:52:47 +09:00
parent 17486efa8b
commit 89c755ca42
7 changed files with 954 additions and 11 deletions

View File

@@ -0,0 +1,106 @@
import type { BattleScenarioId } from './battles';
import type { CampaignState } from '../state/campaignState';
export const firstPursuitScoutVisitId = 'first-pursuit-scout-tent';
export const firstPursuitScoutSourceBattleId =
'first-battle-zhuo-commandery';
export const firstPursuitScoutTargetBattleId =
'second-battle-yellow-turban-pursuit';
export const firstPursuitScoutChoiceIds = [
'trace-river-ambush',
'mark-village-relief'
] as const;
export type FirstPursuitScoutChoiceId =
(typeof firstPursuitScoutChoiceIds)[number];
type FirstPursuitScoutChoiceMemory = {
choiceLabel: string;
campSummaryLine: string;
openingLines: readonly [string, string];
};
export type FirstPursuitScoutMemory = {
sourceBattleId: typeof firstPursuitScoutSourceBattleId;
targetBattleId: typeof firstPursuitScoutTargetBattleId;
visitId: typeof firstPursuitScoutVisitId;
choiceId: FirstPursuitScoutChoiceId;
choiceLabel: string;
deploymentIntentPreview: true;
campSummaryLine: string;
openingLines: [string, string];
};
export type ResolveFirstPursuitScoutMemoryOptions = {
campaign: CampaignState;
battleId: BattleScenarioId;
};
const firstPursuitScoutChoiceMemoryById = {
'trace-river-ambush': {
choiceLabel: '강가 매복 흔적을 쫓는다',
campSummaryLine: '군영 준비 · 강가 매복로 정찰 완료',
openingLines: [
'군영 준비 · 강가 매복로 정찰 완료',
'정찰 인계 · 나루 앞 숲과 궁병 엄호선을 먼저 확인하십시오.'
]
},
'mark-village-relief': {
choiceLabel: '마을 구호 길을 표시한다',
campSummaryLine: '군영 준비 · 북쪽 마을 구호로 표식 완료',
openingLines: [
'군영 준비 · 북쪽 마을 구호로 표식 완료',
'정찰 인계 · 마을 진입로와 피난민 퇴로를 우선 확보하십시오.'
]
}
} as const satisfies Record<
FirstPursuitScoutChoiceId,
FirstPursuitScoutChoiceMemory
>;
export function resolveFirstPursuitScoutMemory({
campaign,
battleId
}: ResolveFirstPursuitScoutMemoryOptions): FirstPursuitScoutMemory | undefined {
if (battleId !== firstPursuitScoutTargetBattleId) {
return undefined;
}
const sourceSettlement =
campaign.battleHistory[firstPursuitScoutSourceBattleId];
if (
!sourceSettlement ||
sourceSettlement.battleId !== firstPursuitScoutSourceBattleId ||
sourceSettlement.outcome !== 'victory' ||
!campaign.completedCampVisits.includes(firstPursuitScoutVisitId)
) {
return undefined;
}
const choiceId =
campaign.campVisitChoiceIds[firstPursuitScoutVisitId];
if (!isFirstPursuitScoutChoiceId(choiceId)) {
return undefined;
}
const choiceMemory = firstPursuitScoutChoiceMemoryById[choiceId];
return {
sourceBattleId: firstPursuitScoutSourceBattleId,
targetBattleId: firstPursuitScoutTargetBattleId,
visitId: firstPursuitScoutVisitId,
choiceId,
choiceLabel: choiceMemory.choiceLabel,
deploymentIntentPreview: true,
campSummaryLine: choiceMemory.campSummaryLine,
openingLines: [...choiceMemory.openingLines]
};
}
export function isFirstPursuitScoutChoiceId(
value: unknown
): value is FirstPursuitScoutChoiceId {
return (
typeof value === 'string' &&
(firstPursuitScoutChoiceIds as readonly string[]).includes(value)
);
}

View File

@@ -124,6 +124,10 @@ import {
firstBattleCamaraderieBonusFor,
selectDominantSortieCooperationSnapshot
} from '../data/firstBattleCamaraderieMemory';
import {
resolveFirstPursuitScoutMemory,
type FirstPursuitScoutMemory
} from '../data/firstPursuitScoutMemory';
import {
evaluateSortieOrder,
evaluateSortieOrderProgress,
@@ -3893,6 +3897,7 @@ export class BattleScene extends Phaser.Scene {
private launchSortieResonanceBondId?: string;
private launchSortieRecommendation?: CampaignSortieRecommendationSnapshot;
private firstBattlePreparation: FirstBattlePreparationState = { ...emptyFirstBattlePreparationState };
private firstPursuitScoutMemory?: FirstPursuitScoutMemory;
private prologueVolunteerReassured = false;
private coreResonancePursuitAttempts = 0;
private coreResonancePursuitSuccesses = 0;
@@ -4001,6 +4006,10 @@ export class BattleScene extends Phaser.Scene {
this.firstBattleTutorialSkipButton = undefined;
const campaign = getCampaignState();
this.firstBattlePreparation = this.resolveFirstBattlePreparation(campaign);
this.firstPursuitScoutMemory = resolveFirstPursuitScoutMemory({
campaign,
battleId: battleScenario.id
});
this.prologueVolunteerReassured = campaign.completedTutorialIds.includes(
prologueMilitiaCampCampaignTutorialIds.reassureVolunteer
);
@@ -8582,6 +8591,9 @@ export class BattleScene extends Phaser.Scene {
if (scoutBrief) {
return `${scoutBrief}. 적 표식을 보며 시작 위치를 조정하세요.`;
}
if (this.firstPursuitScoutMemory) {
return `${this.firstPursuitScoutMemory.campSummaryLine}. 간옹이 표시한 적의 첫 움직임을 보며 전열을 조정하세요.`;
}
return '추천 전열이 적용되어 있습니다. 장수를 선택한 뒤 시작 구역 안에서 위치를 바꿀 수 있습니다.';
}
@@ -8605,6 +8617,15 @@ export class BattleScene extends Phaser.Scene {
})();
return `${preparationSummary}\n${strategySummary}`;
}
if (this.firstPursuitScoutMemory) {
const strategySummary = this.launchSortieRecommendation
? `선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`
: (() => {
const order = sortieOrderDefinition(this.launchSortieOrderId);
return `첫 군령 · ${order.label} · ${order.summary}`;
})();
return `${this.firstPursuitScoutMemory.campSummaryLine}\n${strategySummary}`;
}
if (this.launchSortieRecommendation) {
return `선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`;
}
@@ -9651,8 +9672,13 @@ export class BattleScene extends Phaser.Scene {
private enemyIntentForecastEnabled() {
if (
this.phase === 'deployment' &&
this.firstBattlePreparation.eligible &&
this.firstBattlePreparation.scouting &&
(
(
this.firstBattlePreparation.eligible &&
this.firstBattlePreparation.scouting
) ||
this.firstPursuitScoutMemory?.deploymentIntentPreview === true
) &&
!this.battleOutcome
) {
return true;
@@ -17840,6 +17866,7 @@ export class BattleScene extends Phaser.Scene {
...(this.launchSortieRecommendation
? [`선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`]
: []),
...(this.firstPursuitScoutMemory?.openingLines ?? []),
...cityInformationLines,
enemyIntentOpeningGuide(this.isFirstPursuitRoleEffectBattle() ? tacticalInitiativeThreshold : undefined),
...(battleScenario.id === 'first-battle-zhuo-commandery'
@@ -18154,9 +18181,12 @@ export class BattleScene extends Phaser.Scene {
private showSortieDoctrineBanner(event: BattleSavePendingEvent) {
const { title, lines } = event;
const completedCityInformationLines = new Set(this.completedCityInformationBriefingLines());
const firstPursuitScoutLines = new Set(this.firstPursuitScoutMemory?.openingLines ?? []);
const doctrineLines = lines.filter((line) => (
completedCityInformationLines.has(line) ||
firstPursuitScoutLines.has(line) ||
line.startsWith('도원 공명') ||
line.startsWith('핵심 공명') ||
line.startsWith('공명 ') ||
line.startsWith('적 의도') ||
line.startsWith('첫 행동')
@@ -19386,6 +19416,10 @@ export class BattleScene extends Phaser.Scene {
this.launchSortieResonanceBondId = this.validatedLaunchSortieResonanceBondId(
this.launchSortieResonanceBondId
);
this.firstPursuitScoutMemory = resolveFirstPursuitScoutMemory({
campaign: getCampaignState(),
battleId: battleScenario.id
});
this.refreshLaunchCamaraderieMemoryBonus(getCampaignState());
if (this.launchSortieResonanceBondId) {
this.restoreCoreResonancePursuitStats(state.coreResonanceStats);
@@ -28956,6 +28990,36 @@ export class BattleScene extends Phaser.Scene {
};
}
private firstPursuitScoutMemoryDebugState() {
const memory = this.firstPursuitScoutMemory;
if (!memory) {
return {
active: false,
targetBattleId: 'second-battle-yellow-turban-pursuit',
deploymentIntentPreview: false,
forecastVisibleDuringDeployment: false,
campSummaryLine: null,
openingLines: []
};
}
return {
active: true,
sourceBattleId: memory.sourceBattleId,
targetBattleId: memory.targetBattleId,
visitId: memory.visitId,
choiceId: memory.choiceId,
choiceLabel: memory.choiceLabel,
deploymentIntentPreview: memory.deploymentIntentPreview,
forecastVisibleDuringDeployment:
this.phase === 'deployment' &&
this.enemyIntentForecastEnabled(),
forecastCount: this.enemyIntentForecasts.size,
visualCount: this.enemyIntentVisuals.length,
campSummaryLine: memory.campSummaryLine,
openingLines: [...memory.openingLines]
};
}
private firstBattleVolunteerPromiseDebugState() {
const line = volunteerPromiseLineForBattle(
battleScenario.id,
@@ -29105,6 +29169,7 @@ export class BattleScene extends Phaser.Scene {
: null,
battleHud: this.battleHudDebugState(),
firstBattlePreparation: this.firstBattlePreparationDebugState(),
firstPursuitScoutMemory: this.firstPursuitScoutMemoryDebugState(),
firstBattleNarrativeMemory: {
volunteerPromise: this.firstBattleVolunteerPromiseDebugState()
},

View File

@@ -34,6 +34,10 @@ import {
resolveFirstBattleCamaraderieMemory,
type FirstBattleCamaraderieMemory
} from '../data/firstBattleCamaraderieMemory';
import {
firstPursuitScoutVisitId,
resolveFirstPursuitScoutMemory
} from '../data/firstPursuitScoutMemory';
import {
findCityStayAfterBattle,
type CityEquipmentOffer,
@@ -8396,6 +8400,35 @@ const campDialogues: CampDialogue[] = [
];
const campVisits: CampVisitDefinition[] = [
{
id: 'first-pursuit-scout-tent',
title: '북문 추격로 정찰',
location: '탁현 북문 정찰막',
availableAfterBattleIds: [campBattleIds.first],
bondId: 'liu-bei__jian-yong',
description: '간옹과 황건 잔당의 두 갈래 길을 살피고 정찰 기록을 출진 준비에 올립니다.',
lines: [
'간옹: 발자국이 강가와 마을길로 갈렸습니다.',
'유비: 매복도 찾고 백성의 길도 지켜야 하오.',
'정찰 지도에 두 길을 나란히 표시했습니다.'
],
choices: [
{
id: 'trace-river-ambush',
label: '강가 매복 흔적을 쫓는다',
response: '간옹: 강변의 꺾인 갈대와 버려진 횃불을 표시했습니다. 상처약도 챙겨 이 기록을 출진 준비에 올리겠습니다.',
bondExp: 10,
itemRewards: ['상처약 1']
},
{
id: 'mark-village-relief',
label: '마을 구호 길을 표시한다',
response: '간옹: 피난민이 돌아올 샛길과 군량을 나눌 곳을 적었습니다. 콩 한 자루와 이 기록을 다음 전투 준비에 보태겠습니다.',
bondExp: 10,
itemRewards: ['콩 1']
}
]
},
{
id: 'jingzhou-scholar-rumors',
title: '형주 선비들의 모임',
@@ -11419,6 +11452,7 @@ export class CampScene extends Phaser.Scene {
private sortiePrimaryActionButton?: CampActionButtonView;
private sortieNextActionGuideBackground?: Phaser.GameObjects.Rectangle;
private sortieNextActionGuideText?: Phaser.GameObjects.Text;
private sortieBriefingScoutSummaryText?: Phaser.GameObjects.Text;
private firstBattleFollowupCtaButton?: Phaser.GameObjects.Rectangle;
private firstBattleFollowupCard?: Phaser.GameObjects.Rectangle;
private firstBattleFollowupSummaryText?: Phaser.GameObjects.Text;
@@ -12824,6 +12858,14 @@ export class CampScene extends Phaser.Scene {
return resolveFirstBattleCamaraderieMemory(this.campaign);
}
private firstPursuitScoutMemoryForNextSortie() {
const campaign = this.campaign;
const scenario = this.nextSortieScenario();
return campaign && scenario
? resolveFirstPursuitScoutMemory({ campaign, battleId: scenario.id })
: undefined;
}
private firstBattleCamaraderieDialogue(): CampDialogue | undefined {
if (!this.isFirstSortiePrepSlice()) {
return undefined;
@@ -12926,7 +12968,7 @@ export class CampScene extends Phaser.Scene {
this.showCampSaveSlotPanel();
});
const flow = this.currentSortieFlow();
const storyButtonLabel = this.isFinalEpilogueFlow(flow) && this.campaign?.step === flow.campaignStep ? '엔딩 보기' : '다음 이야기';
const storyButtonLabel = this.isFinalEpilogueFlow(flow) && this.campaign?.step === flow.campaignStep ? '엔딩 보기' : '출진 준비';
this.addCommandButton(storyButtonLabel, 1152, 38, 142, () => {
soundDirector.playSelect();
if (this.isFinalEpilogueFlow(flow)) {
@@ -16248,13 +16290,25 @@ export class CampScene extends Phaser.Scene {
const objectiveLabel = hasBattle ? '목표' : '진행';
const terrainLabel = hasBattle ? '지형' : '장소';
const recommendationLabel = hasBattle ? '추천' : '동행';
const scoutMemory = this.firstPursuitScoutMemoryForNextSortie();
this.trackSortie(this.add.text(x + 18, y + 12, briefing.eyebrow, this.textStyle(15, '#9fb0bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 18, y + 34, briefing.title, this.textStyle(22, '#f2e3bf', true))).setDepth(depth + 1);
if (scoutMemory) {
this.sortieBriefingScoutSummaryText = this.trackSortie(
this.add.text(
x + 18,
y + 64,
`${scoutMemory.campSummaryLine} · 배치 단계 적 의도 선공개`,
this.textStyle(12, '#a8ffd0', true)
)
);
this.sortieBriefingScoutSummaryText.setDepth(depth + 1);
}
this.trackSortie(
this.add.text(x + 18, y + 65, briefing.description, {
...this.textStyle(14, '#d4dce6'),
this.add.text(x + 18, y + (scoutMemory ? 84 : 65), briefing.description, {
...this.textStyle(scoutMemory ? 12 : 14, '#d4dce6'),
wordWrap: { width: width - 36, useAdvancedWrap: true },
lineSpacing: 3
lineSpacing: scoutMemory ? 1 : 3
})
).setDepth(depth + 1);
const chipY = y + height - 46;
@@ -20416,6 +20470,7 @@ export class CampScene extends Phaser.Scene {
this.sortiePrimaryActionButton = undefined;
this.sortieNextActionGuideBackground = undefined;
this.sortieNextActionGuideText = undefined;
this.sortieBriefingScoutSummaryText = undefined;
this.firstBattleFollowupCtaButton = undefined;
this.firstBattleFollowupCard = undefined;
this.firstBattleFollowupSummaryText = undefined;
@@ -23225,7 +23280,7 @@ export class CampScene extends Phaser.Scene {
bg.setStrokeStyle(1, palette.gold, 0.58);
const locationLabel = this.currentCityStay()?.city.name ?? this.campSkinSelection?.skin.locationLabel ?? '현지';
this.track(this.add.text(x + 24, y + 22, `${locationLabel} 방문`, this.textStyle(24, '#f2e3bf', true)));
this.track(this.add.text(x + 24, y + 56, '군영 밖 사람들을 만나 정보, 보급, 공명 보상을 얻습니다. 방문은 한 번만 완료할 수 있습니다.', this.textStyle(14, '#d4dce6')));
this.track(this.add.text(x + 24, y + 56, '획득한 정보·보급·공명은 출진 준비와 다음 전투로 이어집니다. 방문은 한 번만 완료니다.', this.textStyle(14, '#d4dce6')));
const visits = this.availableCampVisits();
if (!visits.some((visit) => visit.id === this.selectedVisitId)) {
@@ -23334,7 +23389,7 @@ export class CampScene extends Phaser.Scene {
const bg = this.track(this.add.rectangle(x, y, width, 104, 0x0d141c, 0.9));
bg.setOrigin(0);
bg.setStrokeStyle(1, palette.blue, 0.42);
this.track(this.add.text(x + 14, y + 12, '현지 준비', this.textStyle(17, '#f2e3bf', true)));
this.track(this.add.text(x + 14, y + 12, '현지 준비 · 다음 전투 반영', this.textStyle(17, '#f2e3bf', true)));
this.track(this.add.text(x + 14, y + 40, `방문 ${completed.length}/${visits.length} 완료`, this.textStyle(13, completed.length >= visits.length && visits.length > 0 ? '#a8ffd0' : '#d4dce6', true)));
const isWolongSearch = this.currentSortieFlow().nextBattleId === campBattleIds.seventeenth;
const informationLabel = isWolongSearch
@@ -25005,6 +25060,10 @@ export class CampScene extends Phaser.Scene {
const firstBattleCampFollowup = this.firstBattleCampFollowup();
const firstBattleCamaraderieMemory = this.firstBattleCamaraderieMemory();
const firstBattleCamaraderieDialogue = this.firstBattleCamaraderieDialogue();
const firstPursuitScoutVisit = this.availableCampVisits().find(
(visit) => visit.id === firstPursuitScoutVisitId
);
const firstPursuitScoutMemory = this.firstPursuitScoutMemoryForNextSortie();
const availableCampDialogues = this.availableCampDialogues();
const selectedCampDialogue = availableCampDialogues.find(
(dialogue) => dialogue.id === this.selectedDialogueId
@@ -25164,6 +25223,25 @@ export class CampScene extends Phaser.Scene {
}
}
: null,
firstPursuitScoutMemory: {
visitId: firstPursuitScoutVisitId,
available: Boolean(firstPursuitScoutVisit),
completed: this.completedCampVisits().includes(firstPursuitScoutVisitId),
choiceId: this.campaign?.campVisitChoiceIds[firstPursuitScoutVisitId] ?? null,
choices: firstPursuitScoutVisit?.choices.map((choice) => ({
id: choice.id,
label: choice.label,
response: choice.response,
rewardText: this.visitRewardText(choice)
})) ?? [],
activeForNextSortie: Boolean(firstPursuitScoutMemory),
targetBattleId: firstPursuitScoutMemory?.targetBattleId ?? null,
choiceLabel: firstPursuitScoutMemory?.choiceLabel ?? null,
deploymentIntentPreview: firstPursuitScoutMemory?.deploymentIntentPreview ?? false,
campSummaryLine: firstPursuitScoutMemory?.campSummaryLine ?? null,
openingLines: firstPursuitScoutMemory ? [...firstPursuitScoutMemory.openingLines] : [],
briefingSummaryBounds: this.sortieTextBoundsDebug(this.sortieBriefingScoutSummaryText)
},
selectedDialogue: selectedCampDialogue
? {
id: selectedCampDialogue.id,