feat: carry camp promises into first victory

This commit is contained in:
2026-07-27 05:01:17 +09:00
parent 4d3f8b6a28
commit 0c16ff3c03
11 changed files with 888 additions and 23 deletions

View File

@@ -0,0 +1,299 @@
import type { StoryPage } from './scenario';
import {
sortieOrderCheckIdsByOrder,
sortieOrderDisplayLabel,
sortieOrderIds,
type CampaignSortieOrderId,
type CampaignSortieOrderProgressId,
type CampaignSortieOrderProgressSnapshot
} from './sortieOrders';
export const firstBattleNarrativeBattleId = 'first-battle-zhuo-commandery';
export const firstBattleVolunteerPromiseEventKey = 'first-engagement';
export const firstBattleVolunteerPromiseText =
'젊은 의병 · 말씀대로 곁의 동료와 보조를 맞추겠습니다.';
const victoryMemoryPageIds = [
'first-victory-guan-yu',
'first-victory-zhang-fei'
] as const;
const missedCriterionLabels: Record<Exclude<CampaignSortieOrderProgressId, 'victory'>, string> = {
'elite-grade': '전투 평가',
'elite-survival': '전원 생존',
'mobile-quick': '제한 턴',
'mobile-breakthrough': '돌파·의도 파훼',
'siege-objective': '추가 목표',
'siege-front': '전열 기여',
'siege-support': '후원 기여'
};
export type FirstBattleNarrativeReport = {
battleId?: unknown;
outcome?: unknown;
turnNumber?: unknown;
mvp?: {
unitId?: unknown;
name?: unknown;
};
sortieOrder?: {
orderId?: unknown;
achieved?: unknown;
progress?: readonly {
id?: unknown;
achieved?: unknown;
value?: unknown;
target?: unknown;
}[];
};
};
export type FirstBattleVictoryNarrativeFallbackReason =
| 'missing-report'
| 'battle-mismatch'
| 'non-victory'
| 'missing-order'
| 'invalid-order-progress';
export type FirstBattleVictoryNarrativeMemory = {
source: 'campaign' | 'fallback';
fallbackReason: FirstBattleVictoryNarrativeFallbackReason | null;
battleId: string | null;
orderId: CampaignSortieOrderId | null;
orderLabel: string | null;
achieved: boolean | null;
turnNumber: number | null;
mvp: { unitId: string; name: string } | null;
progress: CampaignSortieOrderProgressSnapshot[];
achievedCriterionCount: number;
criterionCount: number;
missedCriterionIds: CampaignSortieOrderProgressId[];
appliedPageIds: string[];
};
export type FirstBattleVictoryNarrativeResolution = {
pages: StoryPage[];
memory: FirstBattleVictoryNarrativeMemory;
};
export function volunteerPromiseLineForBattle(
battleId: string,
reassuredInCamp: boolean
) {
return battleId === firstBattleNarrativeBattleId && reassuredInCamp
? firstBattleVolunteerPromiseText
: undefined;
}
export function resolveFirstBattleVictoryNarrative(
sourcePages: readonly StoryPage[],
report?: FirstBattleNarrativeReport | null
): FirstBattleVictoryNarrativeResolution {
const pages = sourcePages.map((page) => ({ ...page }));
const fallback = fallbackReason(report);
if (fallback) {
return {
pages,
memory: fallbackMemory(report, fallback)
};
}
const order = report!.sortieOrder!;
const orderId = order.orderId as CampaignSortieOrderId;
const progress = normalizeProgress(orderId, order.progress!);
const criterionProgress = progress.filter((entry) => entry.id !== 'victory');
const missedCriterionIds = criterionProgress
.filter((entry) => !entry.achieved)
.map((entry) => entry.id);
const orderLabel = sortieOrderDisplayLabel(firstBattleNarrativeBattleId, orderId);
const turnNumber = positiveInteger(report!.turnNumber);
const mvp = normalizeMvp(report!.mvp);
const achieved = order.achieved === true;
const textByPageId = new Map<string, string>([
[
victoryMemoryPageIds[0],
guanYuVictoryMemoryText(orderLabel, achieved, missedCriterionIds, turnNumber)
],
[
victoryMemoryPageIds[1],
zhangFeiVictoryMemoryText(orderLabel, achieved, missedCriterionIds, turnNumber, mvp)
]
]);
const appliedPageIds: string[] = [];
pages.forEach((page) => {
const text = textByPageId.get(page.id);
if (text) {
page.text = text;
appliedPageIds.push(page.id);
}
});
return {
pages,
memory: {
source: 'campaign',
fallbackReason: null,
battleId: firstBattleNarrativeBattleId,
orderId,
orderLabel,
achieved,
turnNumber,
mvp,
progress,
achievedCriterionCount: criterionProgress.filter((entry) => entry.achieved).length,
criterionCount: criterionProgress.length,
missedCriterionIds,
appliedPageIds
}
};
}
function fallbackReason(
report?: FirstBattleNarrativeReport | null
): FirstBattleVictoryNarrativeFallbackReason | undefined {
if (!report) {
return 'missing-report';
}
if (report.battleId !== firstBattleNarrativeBattleId) {
return 'battle-mismatch';
}
if (report.outcome !== 'victory') {
return 'non-victory';
}
if (!report.sortieOrder) {
return 'missing-order';
}
if (
typeof report.sortieOrder.orderId !== 'string' ||
!(sortieOrderIds as readonly string[]).includes(report.sortieOrder.orderId) ||
typeof report.sortieOrder.achieved !== 'boolean' ||
!Array.isArray(report.sortieOrder.progress)
) {
return 'invalid-order-progress';
}
const orderId = report.sortieOrder.orderId as CampaignSortieOrderId;
const expectedIds = sortieOrderCheckIdsByOrder[orderId];
const progressIds = new Set(
report.sortieOrder.progress
.filter((entry) => typeof entry?.id === 'string' && typeof entry.achieved === 'boolean')
.map((entry) => entry.id)
);
return expectedIds.every((id) => progressIds.has(id))
? undefined
: 'invalid-order-progress';
}
function fallbackMemory(
report: FirstBattleNarrativeReport | null | undefined,
reason: FirstBattleVictoryNarrativeFallbackReason
): FirstBattleVictoryNarrativeMemory {
return {
source: 'fallback',
fallbackReason: reason,
battleId: typeof report?.battleId === 'string' ? report.battleId : null,
orderId: null,
orderLabel: null,
achieved: null,
turnNumber: positiveInteger(report?.turnNumber),
mvp: normalizeMvp(report?.mvp),
progress: [],
achievedCriterionCount: 0,
criterionCount: 0,
missedCriterionIds: [],
appliedPageIds: []
};
}
function normalizeProgress(
orderId: CampaignSortieOrderId,
rawProgress: NonNullable<NonNullable<FirstBattleNarrativeReport['sortieOrder']>['progress']>
) {
return sortieOrderCheckIdsByOrder[orderId].map((id) => {
const raw = rawProgress.find((entry) => entry?.id === id)!;
return {
id,
achieved: raw.achieved === true,
value: nonNegativeNumber(raw.value),
target: nonNegativeNumber(raw.target)
};
});
}
function normalizeMvp(raw: FirstBattleNarrativeReport['mvp']) {
const name = typeof raw?.name === 'string'
? raw.name.trim().replace(/\s+/g, ' ').slice(0, 12)
: '';
if (!name) {
return null;
}
const unitId = typeof raw?.unitId === 'string'
? raw.unitId.trim().slice(0, 80)
: '';
return { unitId, name };
}
function positiveInteger(value: unknown) {
const number = typeof value === 'number' ? value : Number.NaN;
return Number.isFinite(number) && number > 0
? Math.floor(number)
: null;
}
function nonNegativeNumber(value: unknown) {
const number = typeof value === 'number' ? value : Number.NaN;
return Number.isFinite(number) && number >= 0 ? number : 0;
}
function missedCriterionSummary(ids: CampaignSortieOrderProgressId[]) {
const firstId = ids.find((id): id is Exclude<CampaignSortieOrderProgressId, 'victory'> => (
id !== 'victory'
));
if (!firstId) {
return '전투의 완성도';
}
const additionalCount = ids.filter((id) => id !== 'victory').length - 1;
return additionalCount > 0
? `${missedCriterionLabels[firstId]}${additionalCount}가지`
: missedCriterionLabels[firstId];
}
function guanYuVictoryMemoryText(
orderLabel: string,
achieved: boolean,
missedIds: CampaignSortieOrderProgressId[],
turnNumber: number | null
) {
if (achieved) {
const record = turnNumber
? `${turnNumber}턴의 전황도 정리했습니다.`
: '모두 함께 돌아온 것이 오늘의 가장 큰 전공입니다.';
return `${orderLabel} 군령의 조건을 모두 지켰습니다. ${record}`;
}
const record = turnNumber
? `${turnNumber}턴의 기록을 다음 출전에서 보완하겠습니다.`
: '다음 출전에서는 그 약속부터 바로 세우겠습니다.';
return `${orderLabel} 군령으로 승리했지만 남은 과제는 ${missedCriterionSummary(missedIds)}입니다. ${record}`;
}
function zhangFeiVictoryMemoryText(
orderLabel: string,
achieved: boolean,
missedIds: CampaignSortieOrderProgressId[],
turnNumber: number | null,
mvp: { unitId: string; name: string } | null
) {
const mvpLine = mvp && mvp.unitId !== 'zhang-fei'
? `전공의 주인공은 ${mvp.name}요!`
: '전공은 셋 모두의 몫이오!';
if (achieved) {
const turnLine = turnNumber ? `${turnNumber}턴 만에 길을 열었고, ` : '';
return `하하, ${orderLabel} 군령까지 완수했소! ${turnLine}${mvpLine}`;
}
const subject = turnNumber
? `${turnNumber}턴의 전공에`
: mvp && mvp.unitId !== 'zhang-fei'
? `${mvp.name}의 전공에`
: '우리의 전공에';
return `이기긴 했지만 ${orderLabel} 군령은 놓쳤구려. ${subject} ${missedCriterionSummary(missedIds)}까지 더해 다음에는 약속도 지키겠소!`;
}

View File

@@ -285,6 +285,13 @@ export const prologueMilitiaCampNpcDefinitions: readonly PrologueMilitiaCampNpcD
textureKey: 'unit-liu-bei',
text: '두려움을 숨길 필요는 없네. 곁의 동료와 보조를 맞추고 위험하면 신호하게. 혼자 싸우게 두지 않겠네.'
}
],
repeatDialogue: [
{
speaker: '젊은 의병',
textureKey: 'unit-shu-infantry',
text: '아직 떨리지만, 곁의 동료와 보조를 맞추겠습니다. 혼자 싸우지 않겠습니다.'
}
]
}
];

View File

@@ -116,6 +116,10 @@ import {
sortieRecommendationDisplayUnitIds,
type SortieRecommendationOutcome
} from '../data/sortieRecommendationOutcome';
import {
firstBattleVolunteerPromiseEventKey,
volunteerPromiseLineForBattle
} from '../data/firstBattleNarrativeMemory';
import {
evaluateSortieOrder,
evaluateSortieOrderProgress,
@@ -3878,6 +3882,7 @@ export class BattleScene extends Phaser.Scene {
private launchSortieResonanceBondId?: string;
private launchSortieRecommendation?: CampaignSortieRecommendationSnapshot;
private firstBattlePreparation: FirstBattlePreparationState = { ...emptyFirstBattlePreparationState };
private prologueVolunteerReassured = false;
private coreResonancePursuitAttempts = 0;
private coreResonancePursuitSuccesses = 0;
private coreResonancePursuitFailures = 0;
@@ -3982,6 +3987,9 @@ export class BattleScene extends Phaser.Scene {
this.firstBattleTutorialSkipButton = undefined;
const campaign = getCampaignState();
this.firstBattlePreparation = this.resolveFirstBattlePreparation(campaign);
this.prologueVolunteerReassured = campaign.completedTutorialIds.includes(
prologueMilitiaCampCampaignTutorialIds.reassureVolunteer
);
const selectedOrderId = campaign.sortieOrderSelection?.battleId === battleScenario.id
? campaign.sortieOrderSelection.orderId
: undefined;
@@ -12168,10 +12176,7 @@ export class BattleScene extends Phaser.Scene {
return;
}
this.triggerBattleEvent('first-engagement', '첫 교전', [
`${attacker.name}${target.name}을 공격합니다.`,
'공격 후 경험치와 장비 경험치가 전투창에서 정산됩니다.'
], { playCue: false });
this.triggerFirstEngagementEvent(attacker, target);
this.phase = 'animating';
const result = this.resolveCombatAction(attacker, target, action, false, usable);
this.clearMarkers();
@@ -12262,6 +12267,18 @@ export class BattleScene extends Phaser.Scene {
return parts.join(' ') || '보정 없음';
}
private triggerFirstEngagementEvent(attacker: UnitData, target: UnitData) {
const volunteerPromiseLine = volunteerPromiseLineForBattle(
battleScenario.id,
this.prologueVolunteerReassured
);
this.triggerBattleEvent(firstBattleVolunteerPromiseEventKey, '첫 교전', [
`${attacker.name} · ${target.name} 공격을 시작합니다.`,
'공격 후 경험치와 장비 경험치가 전투창에서 정산됩니다.',
...(volunteerPromiseLine ? [volunteerPromiseLine] : [])
], { playCue: false });
}
private battleBuffShortText(buff: BattleBuffState) {
const parts = [
buff.attackBonus > 0 ? `공+${buff.attackBonus}` : '',
@@ -28790,6 +28807,31 @@ export class BattleScene extends Phaser.Scene {
};
}
private firstBattleVolunteerPromiseDebugState() {
const line = volunteerPromiseLineForBattle(
battleScenario.id,
this.prologueVolunteerReassured
);
const eventState = this.triggeredBattleEvents.has(firstBattleVolunteerPromiseEventKey)
? 'completed'
: this.activeBattleEvent?.key === firstBattleVolunteerPromiseEventKey
? 'active'
: this.battleEventQueue.some((event) => event.key === firstBattleVolunteerPromiseEventKey)
? 'queued'
: 'waiting';
return {
completedInCamp: this.prologueVolunteerReassured,
eligible: Boolean(line),
eventKey: firstBattleVolunteerPromiseEventKey,
line: line ?? null,
eventState,
bannerBounds:
this.activeBattleEvent?.key === firstBattleVolunteerPromiseEventKey
? this.gameObjectCollectionBoundsDebug(this.battleEventObjects.slice(1))
: null
};
}
private firstBattleTutorialDebugState() {
const path = this.firstBattleTutorialPath;
return {
@@ -28914,6 +28956,9 @@ export class BattleScene extends Phaser.Scene {
: null,
battleHud: this.battleHudDebugState(),
firstBattlePreparation: this.firstBattlePreparationDebugState(),
firstBattleNarrativeMemory: {
volunteerPromise: this.firstBattleVolunteerPromiseDebugState()
},
firstBattleTutorial: this.firstBattleTutorialDebugState(),
combatCutIn: combatCutInDebug,
combatCutInStage: combatCutInDebug,
@@ -29305,6 +29350,23 @@ export class BattleScene extends Phaser.Scene {
return battleUnits.find((unit) => unit.id === unitId);
}
debugTriggerFirstEngagementEvent() {
if (
!this.debugToolsEnabled() ||
this.battleOutcome ||
this.isBattleEventKnown(firstBattleVolunteerPromiseEventKey)
) {
return false;
}
const attacker = battleUnits.find((unit) => unit.faction === 'ally' && unit.hp > 0);
const target = battleUnits.find((unit) => unit.faction === 'enemy' && unit.hp > 0);
if (!attacker || !target) {
return false;
}
this.triggerFirstEngagementEvent(attacker, target);
return this.isBattleEventKnown(firstBattleVolunteerPromiseEventKey);
}
debugBattleUnits() {
return this.debugToolsEnabled() ? battleUnits : undefined;
}

View File

@@ -147,6 +147,10 @@ const objectiveTutorialIds: Record<PrologueMilitiaCampRequiredObjectiveId, Campa
'inspect-arms': prologueMilitiaCampCampaignTutorialIds.inspectArms
};
const optionalDialogueTutorialIds: Partial<Record<string, CampaignTutorialId>> = {
'nervous-volunteer': prologueMilitiaCampCampaignTutorialIds.reassureVolunteer
};
function campDialoguePortraitEntries() {
return campDialoguePortraitKeys
.map((portraitKey) => portraitAssetEntryForStoryContext(
@@ -408,6 +412,11 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
})) ?? []
};
}),
optionalDialogues: Object.entries(optionalDialogueTutorialIds).map(([npcId, tutorialId]) => ({
npcId,
tutorialId,
completed: tutorialId ? hasCompletedCampaignTutorial(tutorialId) : false
})),
exitUnlocked: this.allRequiredObjectivesComplete(),
npcs: Array.from(this.npcViews.values()).map(({ definition, sprite }) => ({
id: definition.id,
@@ -1538,9 +1547,12 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
return;
}
const optionalTutorialId = optionalDialogueTutorialIds[view.definition.id];
const completed = view.definition.objectiveId
? this.isObjectiveComplete(view.definition.objectiveId)
: false;
: optionalTutorialId
? hasCompletedCampaignTutorial(optionalTutorialId)
: false;
const dialogue = completed && view.definition.repeatDialogue?.length
? view.definition.repeatDialogue
: view.definition.dialogue;
@@ -1548,6 +1560,10 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
if (view.definition.objectiveId && !completed) {
this.completeObjective(view.definition.objectiveId);
}
if (optionalTutorialId && !completed) {
completeCampaignTutorial(optionalTutorialId);
this.showCompletionToast('젊은 의병이 약속을 기억합니다.');
}
}, view.definition.id);
}

View File

@@ -32,6 +32,11 @@ import {
resolveCampaignPresentationSoundscape,
type CampaignPresentationStage
} from '../data/campaignPresentationProfiles';
import {
firstBattleNarrativeBattleId,
resolveFirstBattleVictoryNarrative,
type FirstBattleVictoryNarrativeMemory
} from '../data/firstBattleNarrativeMemory';
import {
ensureUnitAnimations,
loadUnitBaseSheets,
@@ -216,6 +221,7 @@ export class StoryScene extends Phaser.Scene {
private storyAssetFailures = new Set<string>();
private ownedStoryTextureKeys = new Set<string>();
private rewardDisplay: StoryRewardDisplayModel = this.emptyRewardDisplay();
private firstBattleNarrativeMemory?: FirstBattleVictoryNarrativeMemory;
private visualMotionReduced = false;
private storyEnvironmentProfile?: StoryEnvironmentProfile;
private storyEnvironmentBackgroundKey?: string;
@@ -231,12 +237,20 @@ export class StoryScene extends Phaser.Scene {
}
init(data?: StorySceneData) {
this.pages = data?.pages?.length ? data.pages : prologueOpeningPages();
this.selectedStoryBackgroundKeys = storyBackgroundKeysForPages(this.pages);
const sourcePages = data?.pages?.length ? data.pages : prologueOpeningPages();
this.nextScene = data?.nextScene ?? 'PrologueVillageScene';
this.nextSceneData = data?.nextSceneData;
this.presentationBattleId = data?.presentationBattleId;
this.presentationStage = data?.presentationStage ?? 'story';
const firstBattleAftermath = (
this.presentationBattleId === firstBattleNarrativeBattleId &&
this.presentationStage === 'aftermath'
)
? resolveFirstBattleVictoryNarrative(sourcePages, getFirstBattleReport())
: undefined;
this.pages = firstBattleAftermath?.pages ?? sourcePages;
this.firstBattleNarrativeMemory = firstBattleAftermath?.memory;
this.selectedStoryBackgroundKeys = storyBackgroundKeysForPages(this.pages);
this.presentationWash = undefined;
this.pageIndex = 0;
this.transitioning = false;
@@ -279,6 +293,8 @@ export class StoryScene extends Phaser.Scene {
pageIndex: this.pageIndex,
totalPages: this.pages.length,
currentPageId: page?.id,
currentSpeaker: page?.speaker ?? null,
currentText: this.bodyText?.text ?? page?.text ?? '',
requestedBackground: page?.background,
background: page ? this.pageBackgroundKey(page) : undefined,
backgroundReady: page ? this.textures.exists(this.pageBackgroundKey(page)) : false,
@@ -319,6 +335,20 @@ export class StoryScene extends Phaser.Scene {
...this.rewardDisplay,
items: this.rewardDisplay.items.map((item) => ({ ...item, icon: { ...item.icon } }))
},
firstBattleNarrativeMemory: this.firstBattleNarrativeMemory
? {
...this.firstBattleNarrativeMemory,
mvp: this.firstBattleNarrativeMemory.mvp
? { ...this.firstBattleNarrativeMemory.mvp }
: null,
progress: this.firstBattleNarrativeMemory.progress.map((entry) => ({ ...entry })),
missedCriterionIds: [...this.firstBattleNarrativeMemory.missedCriterionIds],
appliedPageIds: [...this.firstBattleNarrativeMemory.appliedPageIds],
currentPageApplied: Boolean(
page && this.firstBattleNarrativeMemory.appliedPageIds.includes(page.id)
)
}
: null,
environment: this.storyEnvironmentDebugState(),
presentation: this.storyPresentationDebugState(),
assetStreaming: {

View File

@@ -479,6 +479,7 @@ export const prologueMilitiaCampCampaignTutorialIds = {
reviewScoutReport: 'prologue-camp-review-scout-report',
readyVanguard: 'prologue-camp-ready-vanguard',
inspectArms: 'prologue-camp-inspect-arms',
reassureVolunteer: 'prologue-camp-reassure-volunteer',
complete: 'prologue-camp-complete'
} as const;