feat: carry third-camp preparation into battle

This commit is contained in:
2026-07-27 13:09:59 +09:00
parent b2e7c8b403
commit adf9e65668
12 changed files with 5307 additions and 43 deletions

View File

@@ -0,0 +1,639 @@
import {
resolveThirdBattlePriorityReturnDialogue,
thirdBattleReturnSourceBattleId
} from './thirdBattleReturnDialogue';
export const thirdCampPreparationSourceBattleId =
thirdBattleReturnSourceBattleId;
export const thirdCampPreparationTargetBattleId =
'fourth-battle-guangzong-camp';
export const thirdCampPreparationSelectionRecordId =
'third-camp-preparation-priority';
export const thirdCampPreparationActiveThroughTurn = 3;
export const thirdCampPreparationInformationEnemyUnitId =
'guangzong-main-vanguard-a';
export const thirdCampPreparationPriorityIds = [
'information',
'equipment',
'companion'
] as const;
export type ThirdCampPreparationPriorityId =
(typeof thirdCampPreparationPriorityIds)[number];
export type ThirdCampPreparationInformationEffect = {
kind: 'marked-vanguard-intel';
activeThroughTurn: typeof thirdCampPreparationActiveThroughTurn;
deploymentPreviewCount: 1;
trackedEnemyUnitId:
typeof thirdCampPreparationInformationEnemyUnitId;
accuracyBonus: 10;
maxBattleTriggers: 1;
};
export type ThirdCampPreparationEquipmentEffect = {
kind: 'prepared-equipment-guard';
activeThroughTurn: typeof thirdCampPreparationActiveThroughTurn;
incomingDamageReductionPercent: 8;
minimumDamage: 1;
maxBattleTriggers: 1;
targetRule: 'first-allied-hit';
};
export type ThirdCampPreparationCompanionEffect = {
kind: 'return-bond-opening';
activeThroughTurn: typeof thirdCampPreparationActiveThroughTurn;
cooperationRateBonus: 5;
};
export type ThirdCampPreparationEffect =
| ThirdCampPreparationInformationEffect
| ThirdCampPreparationEquipmentEffect
| ThirdCampPreparationCompanionEffect;
export type ThirdCampPreparationPriorityDefinition = {
id: ThirdCampPreparationPriorityId;
label: string;
activityLabel: string;
summary: string;
deploymentLine: string;
activeLine: string;
appliedLine: string;
unusedLine: string;
effect: ThirdCampPreparationEffect;
};
export const thirdCampPreparationPriorities: readonly
ThirdCampPreparationPriorityDefinition[] = [
{
id: 'information',
label: '본영 정보',
activityLabel: '승전 보고 확인',
summary:
'광종 본영 선봉 한 명의 첫 행동을 배치 때 확인하고, 3턴 안 첫 공격 명중을 10%p 높입니다.',
deploymentLine:
'본영 정보 · 선봉 적 1명의 의도와 표식을 배치 단계에서 먼저 확인',
activeLine:
'본영 정보 · 첫 3턴 안 표식 대상 첫 공격 명중 +10%p',
appliedLine:
'본영 정보 · 표식 대상 첫 공격 명중 보정 적용',
unusedLine:
'본영 정보 · 표식 대상을 공격하기 전에 준비 시간이 끝남',
effect: {
kind: 'marked-vanguard-intel',
activeThroughTurn: thirdCampPreparationActiveThroughTurn,
deploymentPreviewCount: 1,
trackedEnemyUnitId:
thirdCampPreparationInformationEnemyUnitId,
accuracyBonus: 10,
maxBattleTriggers: 1
}
},
{
id: 'equipment',
label: '장비 정비',
activityLabel: '승전 장비 확인',
summary:
'3차 승전 장비를 확인하면 첫 3턴 아군의 첫 피격 피해를 한 번 8% 줄입니다.',
deploymentLine:
'장비 정비 · 아군 첫 피격 1회 방호 준비',
activeLine:
'장비 정비 · 첫 3턴 동안 아군 첫 피격 1회 피해 -8%',
appliedLine:
'장비 정비 · 아군 첫 피격 방호 적용',
unusedLine:
'장비 정비 · 방호가 발동하기 전에 준비 시간이 끝남',
effect: {
kind: 'prepared-equipment-guard',
activeThroughTurn: thirdCampPreparationActiveThroughTurn,
incomingDamageReductionPercent: 8,
minimumDamage: 1,
maxBattleTriggers: 1,
targetRule: 'first-allied-hit'
}
},
{
id: 'companion',
label: '동료 공명',
activityLabel: '우선 귀환 대화',
summary:
'우선 귀환 대화를 나눈 공명조는 첫 3턴 협공 확률이 5%p 오릅니다.',
deploymentLine:
'동료 공명 · 귀환 대화 공명조의 초반 협공 신호 준비',
activeLine:
'동료 공명 · 첫 3턴 동안 해당 공명조 협공 확률 +5%p',
appliedLine:
'동료 공명 · 귀환 대화에서 맞춘 협공 신호 발동',
unusedLine:
'동료 공명 · 해당 공명조가 초반 협공을 잇지 못함',
effect: {
kind: 'return-bond-opening',
activeThroughTurn: thirdCampPreparationActiveThroughTurn,
cooperationRateBonus: 5
}
}
] as const;
export type ThirdCampPreparationEvidence =
| {
kind: 'victory-reward-category';
battleId: typeof thirdCampPreparationSourceBattleId;
category: 'unlocks' | 'equipment';
}
| {
kind: 'priority-return-dialogue';
battleId: typeof thirdCampPreparationSourceBattleId;
dialogueId: string;
bondId: string;
unitIds: [string, string];
};
export type ThirdCampPreparationUnavailableReason =
| 'battle-record-required'
| 'report-review-required'
| 'equipment-change-required'
| 'priority-dialogue-required';
export type ThirdCampPreparationAvailability = {
priority: ThirdCampPreparationPriorityDefinition;
selectable: boolean;
evidence?: ThirdCampPreparationEvidence;
unavailableReason?: ThirdCampPreparationUnavailableReason;
};
export type ResolvedThirdCampPreparationEffect =
| ThirdCampPreparationInformationEffect
| ThirdCampPreparationEquipmentEffect
| (ThirdCampPreparationCompanionEffect & {
bondId: string;
unitIds: [string, string];
});
export type ThirdCampPreparationMemory = {
sourceBattleId: typeof thirdCampPreparationSourceBattleId;
targetBattleId: typeof thirdCampPreparationTargetBattleId;
selectionRecordId: typeof thirdCampPreparationSelectionRecordId;
priorityId: ThirdCampPreparationPriorityId;
label: string;
summary: string;
deploymentLine: string;
activeLine: string;
appliedLine: string;
unusedLine: string;
evidence: ThirdCampPreparationEvidence;
effect: ResolvedThirdCampPreparationEffect;
};
export type ThirdCampPreparationBattlePresentation = {
selectionRecordId: typeof thirdCampPreparationSelectionRecordId;
priorityId: ThirdCampPreparationPriorityId;
label: string;
status:
| 'deployment'
| 'active'
| 'expired'
| 'applied'
| 'unused';
text: string;
activeThroughTurn: typeof thirdCampPreparationActiveThroughTurn;
turnNumber: number | null;
usageCount: number;
};
export type ThirdCampPreparationCampaignState = {
step?: unknown;
latestBattleId?: unknown;
battleHistory?: Readonly<
Record<string, ThirdCampPreparationBattleRecord | null | undefined>
>;
firstBattleReport?: ThirdCampPreparationBattleRecord | null;
completedCampDialogues?: readonly unknown[];
thirdCampPreparationSelection?: {
sourceBattleId?: unknown;
targetBattleId?: unknown;
priorityId?: unknown;
} | null;
acknowledgedVictoryRewardCategories?: Readonly<
Record<string, readonly unknown[] | undefined>
>;
};
type ThirdCampPreparationBattleRecord = {
battleId?: unknown;
outcome?: unknown;
turnNumber?: unknown;
objectives?: unknown;
units?: unknown;
};
type ThirdBattleRecordSelection = {
record: ThirdCampPreparationBattleRecord;
source: 'battle-history' | 'legacy-report';
};
const companionBondByDialogueId: Readonly<
Record<string, { bondId: string; unitIds: [string, string] }>
> = {
'liu-guan-after-guangzong-road': {
bondId: 'liu-bei__guan-yu',
unitIds: ['liu-bei', 'guan-yu']
},
'liu-zhang-after-guangzong-road': {
bondId: 'liu-bei__zhang-fei',
unitIds: ['liu-bei', 'zhang-fei']
},
'guan-zhang-after-guangzong-road': {
bondId: 'guan-yu__zhang-fei',
unitIds: ['guan-yu', 'zhang-fei']
}
};
export function isThirdCampPreparationPriorityId(
value: unknown
): value is ThirdCampPreparationPriorityId {
return (
typeof value === 'string' &&
(thirdCampPreparationPriorityIds as readonly string[]).includes(
value
)
);
}
export function getThirdCampPreparationPriority(
priorityId: unknown
): ThirdCampPreparationPriorityDefinition | undefined {
const definition = isThirdCampPreparationPriorityId(priorityId)
? thirdCampPreparationPriorities.find(
(candidate) => candidate.id === priorityId
)
: undefined;
return definition ? clonePriority(definition) : undefined;
}
export function hasThirdCampPreparationSourceVictory(
campaign?: ThirdCampPreparationCampaignState | null
) {
return Boolean(selectThirdBattleVictoryRecord(campaign));
}
export function resolveThirdCampPreparationAvailability(
campaign?: ThirdCampPreparationCampaignState | null
): ThirdCampPreparationAvailability[] {
const source = selectThirdBattleVictoryRecord(campaign);
if (!source) {
return thirdCampPreparationPriorities.map((priority) => ({
priority: clonePriority(priority),
selectable: false,
unavailableReason: 'battle-record-required'
}));
}
const informationEvidence = acknowledgedRewardEvidence(
campaign,
'unlocks'
);
const equipmentEvidence = acknowledgedRewardEvidence(
campaign,
'equipment'
);
const companionEvidence = completedCompanionEvidence(campaign);
const evidenceByPriority: Partial<
Record<ThirdCampPreparationPriorityId, ThirdCampPreparationEvidence>
> = {
...(informationEvidence
? { information: informationEvidence }
: {}),
...(equipmentEvidence ? { equipment: equipmentEvidence } : {}),
...(companionEvidence ? { companion: companionEvidence } : {})
};
const missingReasonByPriority: Record<
ThirdCampPreparationPriorityId,
ThirdCampPreparationUnavailableReason
> = {
information: 'report-review-required',
equipment: 'equipment-change-required',
companion: 'priority-dialogue-required'
};
return thirdCampPreparationPriorities.map((priority) => {
const evidence = evidenceByPriority[priority.id];
return {
priority: clonePriority(priority),
selectable: Boolean(evidence),
...(evidence
? { evidence: cloneEvidence(evidence) }
: {
unavailableReason: missingReasonByPriority[priority.id]
})
};
});
}
export function resolveThirdCampPreparationSelection(
campaign?: ThirdCampPreparationCampaignState | null
) {
const selection = campaign?.thirdCampPreparationSelection;
if (
selection?.sourceBattleId !==
thirdCampPreparationSourceBattleId ||
selection.targetBattleId !==
thirdCampPreparationTargetBattleId
) {
return undefined;
}
const priorityId = selection.priorityId;
if (!isThirdCampPreparationPriorityId(priorityId)) {
return undefined;
}
const availability = resolveThirdCampPreparationAvailability(
campaign
).find((entry) => entry.priority.id === priorityId);
return availability
? {
priorityId,
label: availability.priority.label,
selectable: availability.selectable,
...(availability.evidence
? { evidence: cloneEvidence(availability.evidence) }
: {}),
...(availability.unavailableReason
? { unavailableReason: availability.unavailableReason }
: {})
}
: undefined;
}
export function resolveThirdCampPreparationMemory(options: {
campaign?: ThirdCampPreparationCampaignState | null;
battleId?: string;
}): ThirdCampPreparationMemory | undefined {
if (options.battleId !== thirdCampPreparationTargetBattleId) {
return undefined;
}
const selection = resolveThirdCampPreparationSelection(
options.campaign
);
if (!selection?.selectable || !selection.evidence) {
return undefined;
}
const priority = getThirdCampPreparationPriority(
selection.priorityId
);
if (!priority) {
return undefined;
}
const effect = resolveEffect(priority.effect, selection.evidence);
if (!effect) {
return undefined;
}
return {
sourceBattleId: thirdCampPreparationSourceBattleId,
targetBattleId: thirdCampPreparationTargetBattleId,
selectionRecordId: thirdCampPreparationSelectionRecordId,
priorityId: priority.id,
label: priority.label,
summary: priority.summary,
deploymentLine: priority.deploymentLine,
activeLine: priority.activeLine,
appliedLine: priority.appliedLine,
unusedLine: priority.unusedLine,
evidence: cloneEvidence(selection.evidence),
effect
};
}
export function resolveThirdCampPreparationBattlePresentation(options: {
memory: ThirdCampPreparationMemory;
stage: 'deployment' | 'turn' | 'result';
turnNumber?: number;
usageCount?: number;
}): ThirdCampPreparationBattlePresentation | undefined {
const priority = getThirdCampPreparationPriority(
options.memory.priorityId
);
if (
!priority ||
priority.label !== options.memory.label ||
options.memory.targetBattleId !==
thirdCampPreparationTargetBattleId
) {
return undefined;
}
const usageCount = nonNegativeInteger(options.usageCount);
if (options.stage === 'deployment') {
return presentationBase(
options.memory,
'deployment',
options.memory.deploymentLine,
null,
usageCount
);
}
if (options.stage === 'turn') {
const turnNumber = positiveInteger(options.turnNumber);
if (!turnNumber) {
return undefined;
}
const active =
turnNumber <= thirdCampPreparationActiveThroughTurn;
return presentationBase(
options.memory,
active ? 'active' : 'expired',
active
? `${options.memory.activeLine} · ${turnNumber}/${thirdCampPreparationActiveThroughTurn}`
: `${options.memory.label} · 준비 효과 종료`,
turnNumber,
usageCount
);
}
const applied = usageCount > 0;
return presentationBase(
options.memory,
applied ? 'applied' : 'unused',
applied
? `${options.memory.appliedLine} · ${usageCount}`
: options.memory.unusedLine,
null,
usageCount
);
}
function selectThirdBattleVictoryRecord(
campaign?: ThirdCampPreparationCampaignState | null
): ThirdBattleRecordSelection | undefined {
const history = campaign?.battleHistory;
if (
history &&
Object.prototype.hasOwnProperty.call(
history,
thirdCampPreparationSourceBattleId
)
) {
const record = history[thirdCampPreparationSourceBattleId];
return validThirdBattleVictory(record)
? { record, source: 'battle-history' }
: undefined;
}
const record = campaign?.firstBattleReport;
return validThirdBattleVictory(record)
? { record, source: 'legacy-report' }
: undefined;
}
function validThirdBattleVictory(
record?: ThirdCampPreparationBattleRecord | null
): record is ThirdCampPreparationBattleRecord {
return Boolean(
record &&
record.battleId === thirdCampPreparationSourceBattleId &&
record.outcome === 'victory'
);
}
function acknowledgedRewardEvidence(
campaign: ThirdCampPreparationCampaignState | null | undefined,
category: 'unlocks' | 'equipment'
): Extract<
ThirdCampPreparationEvidence,
{ kind: 'victory-reward-category' }
> | undefined {
const acknowledged =
campaign?.acknowledgedVictoryRewardCategories?.[
thirdCampPreparationSourceBattleId
];
return Array.isArray(acknowledged) &&
acknowledged.includes(category)
? {
kind: 'victory-reward-category',
battleId: thirdCampPreparationSourceBattleId,
category
}
: undefined;
}
function completedCompanionEvidence(
campaign?: ThirdCampPreparationCampaignState | null
): Extract<
ThirdCampPreparationEvidence,
{ kind: 'priority-return-dialogue' }
> | undefined {
const dialogue = resolveThirdBattlePriorityReturnDialogue(campaign);
if (
!dialogue ||
!arrayValue(campaign?.completedCampDialogues).includes(
dialogue.targetDialogueId
)
) {
return undefined;
}
const bond = companionBondByDialogueId[dialogue.targetDialogueId];
return bond
? {
kind: 'priority-return-dialogue',
battleId: thirdCampPreparationSourceBattleId,
dialogueId: dialogue.targetDialogueId,
bondId: bond.bondId,
unitIds: [...bond.unitIds]
}
: undefined;
}
function resolveEffect(
effect: ThirdCampPreparationEffect,
evidence: ThirdCampPreparationEvidence
): ResolvedThirdCampPreparationEffect | undefined {
if (
effect.kind === 'marked-vanguard-intel' &&
evidence.kind === 'victory-reward-category' &&
evidence.category === 'unlocks'
) {
return { ...effect };
}
if (
effect.kind === 'prepared-equipment-guard' &&
evidence.kind === 'victory-reward-category' &&
evidence.category === 'equipment'
) {
return { ...effect };
}
if (
effect.kind === 'return-bond-opening' &&
evidence.kind === 'priority-return-dialogue'
) {
return {
...effect,
bondId: evidence.bondId,
unitIds: [...evidence.unitIds]
};
}
return undefined;
}
function presentationBase(
memory: ThirdCampPreparationMemory,
status: ThirdCampPreparationBattlePresentation['status'],
text: string,
turnNumber: number | null,
usageCount: number
): ThirdCampPreparationBattlePresentation {
return {
selectionRecordId: thirdCampPreparationSelectionRecordId,
priorityId: memory.priorityId,
label: memory.label,
status,
text,
activeThroughTurn: thirdCampPreparationActiveThroughTurn,
turnNumber,
usageCount
};
}
function clonePriority(
priority: ThirdCampPreparationPriorityDefinition
): ThirdCampPreparationPriorityDefinition {
return {
...priority,
effect: { ...priority.effect }
} as ThirdCampPreparationPriorityDefinition;
}
function cloneEvidence(
evidence: ThirdCampPreparationEvidence
): ThirdCampPreparationEvidence {
if (evidence.kind === 'priority-return-dialogue') {
return {
...evidence,
unitIds: [...evidence.unitIds]
};
}
return { ...evidence };
}
function positiveInteger(value: unknown) {
return (
typeof value === 'number' &&
Number.isInteger(value) &&
value > 0 &&
value <= 9999
)
? value
: undefined;
}
function nonNegativeInteger(value: unknown) {
return (
typeof value === 'number' &&
Number.isFinite(value) &&
value > 0
)
? Math.min(999999, Math.floor(value))
: 0;
}
function arrayValue(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}

File diff suppressed because it is too large Load Diff

View File

@@ -49,6 +49,12 @@ import {
thirdBattleReturnCampStep,
thirdBattleReturnSourceBattleId
} from '../data/thirdBattleReturnDialogue';
import {
thirdCampPreparationSourceBattleId,
thirdCampPreparationTargetBattleId,
type ThirdCampPreparationPriorityId,
type ThirdCampPreparationUnavailableReason
} from '../data/thirdCampPreparation';
import {
findCityStayAfterBattle,
type CityEquipmentOffer,
@@ -242,6 +248,10 @@ import {
purchaseCityStayEquipment
} from '../state/cityStayActions';
import { secondBattleReliefProgress } from '../state/secondBattleReliefActions';
import {
setThirdCampPreparationPriority,
thirdCampPreparationProgress
} from '../state/thirdCampPreparationActions';
import { clearCampaignBattleSavesForSlot } from '../state/battleSaveKeys';
import { releaseTextureSource } from '../render/loaderLifecycle';
import { palette } from '../ui/palette';
@@ -412,6 +422,15 @@ type SortiePortraitRosterLayout = {
nextEnabled: boolean;
};
type ThirdCampPreparationCardView = {
priorityId: ThirdCampPreparationPriorityId;
selectable: boolean;
selected: boolean;
unavailableReason?: ThirdCampPreparationUnavailableReason;
background: Phaser.GameObjects.Rectangle;
actionButton: Phaser.GameObjects.Rectangle;
};
type CampSupplyId = 'bean' | 'wine' | 'salve';
type CampSupplyDefinition = {
@@ -11475,6 +11494,10 @@ export class CampScene extends Phaser.Scene {
private firstBattleFollowupCtaButton?: Phaser.GameObjects.Rectangle;
private firstBattleFollowupCard?: Phaser.GameObjects.Rectangle;
private firstBattleFollowupSummaryText?: Phaser.GameObjects.Text;
private thirdCampPreparationBrowserOpen = false;
private thirdCampPreparationToggleButton?: Phaser.GameObjects.Rectangle;
private thirdCampPreparationCloseButton?: Phaser.GameObjects.Rectangle;
private thirdCampPreparationCardViews: ThirdCampPreparationCardView[] = [];
private visitedTabs = new Set<CampTab>();
private selectedSortieUnitIds: string[] = [];
private sortieFormationAssignments: SortieFormationAssignments = {};
@@ -11526,6 +11549,7 @@ export class CampScene extends Phaser.Scene {
init(data?: CampSceneData) {
this.ownedCampTextureKeys.clear();
this.thirdCampPreparationBrowserOpen = false;
if (data?.completedAftermathBattleId) {
completeCampaignAftermath(data.completedAftermathBattleId);
}
@@ -13889,6 +13913,13 @@ export class CampScene extends Phaser.Scene {
return;
}
if (this.sortieObjects.length > 0 && this.thirdCampPreparationBrowserOpen) {
soundDirector.playSelect();
this.thirdCampPreparationBrowserOpen = false;
this.showSortiePrep();
return;
}
if (this.sortieObjects.length > 0 && this.sortieFormationPanelMode !== 'roster') {
soundDirector.playSelect();
this.closeSortieFormationPanelBrowser();
@@ -13912,6 +13943,7 @@ export class CampScene extends Phaser.Scene {
this.equipmentSwapConfirmObjects.length > 0 ||
this.saveSlotObjects.length > 0 ||
this.saveSlotConfirmObjects.length > 0 ||
this.thirdCampPreparationBrowserOpen ||
!this.usesStagedSortiePrep()
) {
return;
@@ -14248,6 +14280,13 @@ export class CampScene extends Phaser.Scene {
return flow.afterBattleId === campBattleIds.first && flow.nextBattleId === campBattleIds.second;
}
private isThirdCampPreparationSlice(flow = this.currentSortieFlow()) {
return (
flow.afterBattleId === thirdCampPreparationSourceBattleId &&
flow.nextBattleId === thirdCampPreparationTargetBattleId
);
}
private usesStagedSortiePrep(flow = this.currentSortieFlow()) {
return Boolean(flow.nextBattleId);
}
@@ -14355,8 +14394,16 @@ export class CampScene extends Phaser.Scene {
}
private renderFirstSortieTacticalNotes(x: number, y: number, width: number, height: number, depth: number) {
if (this.isThirdCampPreparationSlice() && this.thirdCampPreparationBrowserOpen) {
this.renderThirdCampPreparationBrowser(x, y, width, height, depth);
return;
}
const scenario = this.nextSortieScenario();
const firstSortie = this.isFirstSortiePrepSlice();
const thirdCampPreparation = this.isThirdCampPreparationSlice() && this.campaign
? thirdCampPreparationProgress(this.campaign)
: undefined;
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.94));
bg.setOrigin(0);
bg.setDepth(depth);
@@ -14412,26 +14459,46 @@ export class CampScene extends Phaser.Scene {
if (firstBattleFollowup) {
this.firstBattleFollowupCard = command;
}
const commandTitle = firstBattleFollowup
? `지난 전투 인계 · ${firstBattleFollowup.mentorName}`
: '지휘관 판단';
const commandTitle = thirdCampPreparation
? thirdCampPreparation.ready
? `출진 우선 · ${thirdCampPreparation.selectedLabel}`
: '출진 우선 · 하나를 선택'
: firstBattleFollowup
? `지난 전투 인계 · ${firstBattleFollowup.mentorName}`
: '지휘관 판단';
this.trackSortie(this.add.text(x + 34, commandY + 12, commandTitle, this.textStyle(14, '#ffdf7b', true))).setDepth(depth + 2);
const commandSummary = firstBattleFollowup
? firstBattleFollowup.achieved
? `${firstBattleFollowup.originalOrderLabel} 완수 → ${firstBattleFollowup.recommendedNextOrderLabel} · ${firstBattleFollowup.focusCriterionLabel}`
: `${firstBattleFollowup.originalOrderLabel} 보완 → ${firstBattleFollowup.focusCriterionLabel}`
: scenario.tacticalGuide?.summary ?? scenario.openingObjectiveLines[1] ?? (firstSortie
? '세 형제가 서로 다른 길을 맡아 적의 전열을 흔드십시오.'
: '전열·돌파·후원의 균형으로 적의 주력을 나누십시오.');
const selectedPreparation = thirdCampPreparation?.availability.find(
(entry) => entry.priority.id === thirdCampPreparation.selectedPriorityId
);
const commandSummary = thirdCampPreparation
? selectedPreparation?.priority.summary ??
'실제로 확인한 정보·장비·동료 활동 가운데 이번 출진에 먼저 반영할 하나를 정하십시오.'
: firstBattleFollowup
? firstBattleFollowup.achieved
? `${firstBattleFollowup.originalOrderLabel} 완수 → ${firstBattleFollowup.recommendedNextOrderLabel} · ${firstBattleFollowup.focusCriterionLabel}`
: `${firstBattleFollowup.originalOrderLabel} 보완 → ${firstBattleFollowup.focusCriterionLabel}`
: scenario.tacticalGuide?.summary ?? scenario.openingObjectiveLines[1] ?? (firstSortie
? '세 형제가 서로 다른 길을 맡아 적의 전열을 흔드십시오.'
: '전열·돌파·후원의 균형으로 적의 주력을 나누십시오.');
const commandSummaryText = this.trackSortie(
this.add.text(x + 34, commandY + 36, commandSummary, {
...this.textStyle(firstBattleFollowup ? 11 : 13, '#d4dce6'),
wordWrap: { width: firstBattleFollowup ? width - 214 : width - 68, useAdvancedWrap: true },
lineSpacing: firstBattleFollowup ? 2 : 3
...this.textStyle(firstBattleFollowup || thirdCampPreparation ? 11 : 13, '#d4dce6'),
wordWrap: {
width: firstBattleFollowup || thirdCampPreparation ? width - 214 : width - 68,
useAdvancedWrap: true
},
lineSpacing: firstBattleFollowup || thirdCampPreparation ? 2 : 3
})
);
commandSummaryText.setDepth(depth + 2);
if (firstBattleFollowup) {
if (thirdCampPreparation) {
this.renderThirdCampPreparationToggle(
x + width - 86,
commandY + 51,
thirdCampPreparation.ready ? '준비 변경' : '준비 선택',
depth + 2
);
} else if (firstBattleFollowup) {
this.firstBattleFollowupSummaryText = commandSummaryText;
}
if (firstBattleFollowup) {
@@ -14463,6 +14530,320 @@ export class CampScene extends Phaser.Scene {
}
}
private renderThirdCampPreparationToggle(
x: number,
y: number,
label: string,
depth: number
) {
const width = 132;
const button = this.trackSortie(
this.add.rectangle(x, y, width, 34, 0x4a371d, 0.98)
);
button.setDepth(depth);
button.setStrokeStyle(2, palette.gold, 0.92);
button.setInteractive({ useHandCursor: true });
const open = () => {
this.thirdCampPreparationBrowserOpen = true;
soundDirector.playSelect();
this.showSortiePrep();
};
button.on('pointerover', () => button.setFillStyle(0x654c25, 1));
button.on('pointerout', () => button.setFillStyle(0x4a371d, 0.98));
button.on('pointerdown', open);
this.thirdCampPreparationToggleButton = button;
const text = this.trackSortie(
this.add.text(x, y, label, this.textStyle(11, '#fff2b8', true))
);
text.setOrigin(0.5);
text.setDepth(depth + 1);
text.setInteractive({ useHandCursor: true });
text.on('pointerover', () => button.setFillStyle(0x654c25, 1));
text.on('pointerout', () => button.setFillStyle(0x4a371d, 0.98));
text.on('pointerdown', open);
}
private renderThirdCampPreparationBrowser(
x: number,
y: number,
width: number,
height: number,
depth: number
) {
const progress = this.campaign
? thirdCampPreparationProgress(this.campaign)
: undefined;
const background = this.trackSortie(
this.add.rectangle(x, y, width, height, 0x0d141c, 0.98)
);
background.setOrigin(0);
background.setDepth(depth);
background.setStrokeStyle(1, palette.gold, 0.72);
this.trackSortie(
this.add.text(x + 18, y + 14, '이번 출진의 우선 준비', this.textStyle(19, '#f2e3bf', true))
).setDepth(depth + 1);
this.trackSortie(
this.add.text(
x + 18,
y + 41,
'실제로 확인한 활동 하나를 광종 본영전 첫 3턴에 반영합니다.',
this.textStyle(10, '#9fb0bf')
)
).setDepth(depth + 1);
const close = this.trackSortie(
this.add.rectangle(x + width - 29, y + 25, 38, 26, 0x1a2630, 0.96)
);
close.setDepth(depth + 1);
close.setStrokeStyle(1, palette.blue, 0.68);
close.setInteractive({ useHandCursor: true });
const closeBrowser = () => {
this.thirdCampPreparationBrowserOpen = false;
soundDirector.playSelect();
this.showSortiePrep();
};
close.on('pointerover', () => close.setFillStyle(0x283947, 1));
close.on('pointerout', () => close.setFillStyle(0x1a2630, 0.96));
close.on('pointerdown', closeBrowser);
this.thirdCampPreparationCloseButton = close;
const closeLabel = this.trackSortie(
this.add.text(x + width - 29, y + 25, '×', this.textStyle(17, '#f2e3bf', true))
);
closeLabel.setOrigin(0.5);
closeLabel.setDepth(depth + 2);
closeLabel.setInteractive({ useHandCursor: true });
closeLabel.on('pointerdown', closeBrowser);
if (!progress) {
this.trackSortie(
this.add.text(
x + width / 2,
y + height / 2,
'현재 출진에서는 우선 준비를 선택할 수 없습니다.',
this.textStyle(12, '#9fb0bf', true)
)
).setOrigin(0.5).setDepth(depth + 1);
return;
}
const sealByPriority: Record<ThirdCampPreparationPriorityId, string> = {
information: '보',
equipment: '장',
companion: '동'
};
const toneByPriority: Record<ThirdCampPreparationPriorityId, number> = {
information: palette.blue,
equipment: palette.gold,
companion: 0xc887dc
};
progress.availability.forEach((entry, index) => {
const priorityId = entry.priority.id;
const selected = progress.selectedPriorityId === priorityId;
const cardY = y + 68 + index * 106;
const cardHeight = 98;
const fill = selected
? 0x183126
: entry.selectable
? 0x17232e
: 0x111820;
const tone = selected ? palette.green : toneByPriority[priorityId];
const card = this.trackSortie(
this.add.rectangle(x + 16, cardY, width - 32, cardHeight, fill, entry.selectable ? 0.98 : 0.78)
);
card.setOrigin(0);
card.setDepth(depth + 1);
card.setStrokeStyle(selected ? 2 : 1, tone, selected ? 0.96 : entry.selectable ? 0.68 : 0.28);
const seal = this.trackSortie(
this.add.circle(x + 43, cardY + 31, 17, tone, entry.selectable ? 0.9 : 0.38)
);
seal.setDepth(depth + 2);
seal.setStrokeStyle(1, selected ? 0xa8ffd0 : 0xf2e3bf, entry.selectable ? 0.72 : 0.24);
this.trackSortie(
this.add.text(x + 43, cardY + 31, sealByPriority[priorityId], this.textStyle(13, '#f2e3bf', true))
).setOrigin(0.5).setDepth(depth + 3);
this.trackSortie(
this.add.text(
x + 68,
cardY + 10,
`${entry.priority.label} · ${entry.priority.activityLabel}`,
this.textStyle(13, entry.selectable ? '#ffdf7b' : '#77818c', true)
)
).setDepth(depth + 2);
this.trackSortie(
this.add.text(
x + 68,
cardY + 33,
entry.selectable
? entry.priority.summary
: this.thirdCampPreparationUnavailableLabel(entry.unavailableReason),
{
...this.textStyle(10, entry.selectable ? '#d4dce6' : '#7f8994'),
wordWrap: { width: width - 172, useAdvancedWrap: true },
lineSpacing: 2,
maxLines: 3
}
)
).setDepth(depth + 2);
const actionX = x + width - 57;
const actionY = cardY + cardHeight / 2;
const action = this.trackSortie(
this.add.rectangle(
actionX,
actionY,
82,
32,
selected ? 0x28533c : entry.selectable ? 0x4a371d : 0x1a2630,
entry.selectable ? 0.98 : 0.8
)
);
action.setDepth(depth + 2);
action.setStrokeStyle(1, tone, entry.selectable ? 0.82 : 0.34);
action.setInteractive({ useHandCursor: true });
const actionLabel = this.trackSortie(
this.add.text(
actionX,
actionY,
selected
? '선택됨'
: entry.selectable
? '선택'
: this.thirdCampPreparationLockedActionLabel(priorityId),
this.textStyle(10, entry.selectable ? '#fff2b8' : '#9fb0bf', true)
)
);
actionLabel.setOrigin(0.5);
actionLabel.setDepth(depth + 3);
actionLabel.setInteractive({ useHandCursor: true });
const run = () => {
if (entry.selectable) {
this.selectThirdCampPreparationPriority(priorityId);
return;
}
this.openThirdCampPreparationActivity(priorityId);
};
[card, action, actionLabel].forEach((target) => {
target.setInteractive({ useHandCursor: true });
target.on('pointerdown', run);
});
action.on('pointerover', () =>
action.setFillStyle(entry.selectable ? 0x654c25 : 0x283947, 1)
);
action.on('pointerout', () =>
action.setFillStyle(
selected ? 0x28533c : entry.selectable ? 0x4a371d : 0x1a2630,
entry.selectable ? 0.98 : 0.8
)
);
this.thirdCampPreparationCardViews.push({
priorityId,
selectable: entry.selectable,
selected,
...(entry.unavailableReason
? { unavailableReason: entry.unavailableReason }
: {}),
background: card,
actionButton: action
});
});
this.trackSortie(
this.add.text(
x + 18,
y + height - 25,
'권장 선택 · 출진 전에는 언제든 다른 완료 활동으로 바꿀 수 있습니다.',
this.textStyle(10, progress.ready ? '#a8ffd0' : '#ffdf7b', true)
)
).setDepth(depth + 1);
}
private thirdCampPreparationUnavailableLabel(
reason?: ThirdCampPreparationUnavailableReason
) {
if (reason === 'report-review-required') {
return '승전 보고에서 광종 본영 진입 정보를 먼저 확인해야 합니다.';
}
if (reason === 'equipment-change-required') {
return '3차 승리로 받은 장비를 장비 탭에서 먼저 확인해야 합니다.';
}
if (reason === 'priority-dialogue-required') {
return '전투 결과에 맞춰 표시된 우선 귀환 대화를 먼저 마쳐야 합니다.';
}
return '광종 구원로 승리 기록이 있어야 선택할 수 있습니다.';
}
private thirdCampPreparationLockedActionLabel(
priorityId: ThirdCampPreparationPriorityId
) {
if (priorityId === 'equipment') {
return '장비 보기';
}
if (priorityId === 'companion') {
return '대화하기';
}
return '보고 확인';
}
private selectThirdCampPreparationPriority(
priorityId: ThirdCampPreparationPriorityId
) {
const result = setThirdCampPreparationPriority(priorityId);
if (!result.ok) {
const message = result.reason === 'activity-required'
? '관련 활동을 먼저 마쳐야 이 준비를 선택할 수 있습니다.'
: '현재 진행에서는 이 준비를 저장할 수 없습니다.';
this.showCampNotice(message);
return;
}
this.campaign = result.campaign;
this.thirdCampPreparationBrowserOpen = true;
soundDirector.playSelect();
this.showCampNotice(
`${result.priority.label} 선택 · ${result.memory.activeLine}`
);
this.showSortiePrep();
}
private openThirdCampPreparationActivity(
priorityId: ThirdCampPreparationPriorityId
) {
if (priorityId === 'equipment') {
this.thirdCampPreparationBrowserOpen = false;
this.hideSortiePrep();
this.activeTab = 'equipment';
this.render();
this.showCampNotice('3차 승전 장비를 확인했습니다. 출진 준비에서 장비 정비를 선택할 수 있습니다.');
return;
}
if (priorityId === 'companion') {
const priorityDialogue = this.thirdBattlePriorityReturnDialogue();
if (!priorityDialogue) {
this.showCampNotice('현재 승리 기록에서 우선 귀환 대화를 확인할 수 없습니다.');
return;
}
this.thirdCampPreparationBrowserOpen = false;
this.hideSortiePrep();
this.activeTab = 'dialogue';
this.selectedDialogueId = priorityDialogue.targetDialogueId;
this.render();
this.showCampNotice('표시된 우선 귀환 대화를 마치면 동료 공명을 선택할 수 있습니다.');
return;
}
this.acknowledgePendingVictoryRewardCategories(['unlocks']);
this.campaign = getCampaignState();
this.thirdCampPreparationBrowserOpen = true;
soundDirector.playSelect();
this.showCampNotice('승전 보고의 광종 본영 진입 정보를 확인했습니다.');
this.showSortiePrep();
}
private openFirstBattleFollowupOrderBrowser() {
if (!this.firstBattleCampFollowup() || !this.nextSortieScenario()) {
return;
@@ -20211,6 +20592,9 @@ export class CampScene extends Phaser.Scene {
const roleDetail = hasBattle
? `전열 ${roleCounts.front} · 돌파 ${roleCounts.flank} · 후원 ${roleCounts.support}`
: `대표 ${roleCounts.front} · 현장 ${roleCounts.flank} · 보좌 ${roleCounts.support}`;
const thirdCampPreparation = this.isThirdCampPreparationSlice() && this.campaign
? thirdCampPreparationProgress(this.campaign)
: undefined;
return [
{
label: requiredLabel,
@@ -20218,6 +20602,16 @@ export class CampScene extends Phaser.Scene {
detail: requiredDetail,
priority: 'required'
},
...(thirdCampPreparation
? [{
label: '출진 우선 준비',
complete: thirdCampPreparation.ready,
detail: thirdCampPreparation.ready
? `${thirdCampPreparation.selectedLabel} · 첫 3턴 반영`
: '정보·장비·동료 중 완료한 활동 하나 선택',
priority: 'recommended' as const
}]
: []),
{
label: '부상 장수 확인',
complete: recoveryTargets.length === 0,
@@ -20587,6 +20981,9 @@ export class CampScene extends Phaser.Scene {
this.firstBattleFollowupCtaButton = undefined;
this.firstBattleFollowupCard = undefined;
this.firstBattleFollowupSummaryText = undefined;
this.thirdCampPreparationToggleButton = undefined;
this.thirdCampPreparationCloseButton = undefined;
this.thirdCampPreparationCardViews = [];
this.sortiePortraitRosterLayout = undefined;
this.sortieRosterToggleBounds = [];
this.sortieHoveredUnitId = undefined;
@@ -20597,6 +20994,7 @@ export class CampScene extends Phaser.Scene {
this.sortieFormationPanelMode = 'roster';
this.sortieCoreResonanceSelectorOpen = false;
this.sortieCoreResonancePage = 0;
this.thirdCampPreparationBrowserOpen = false;
}
this.sortieComparisonPanelView = undefined;
this.sortiePursuitPanelView = undefined;
@@ -25285,6 +25683,9 @@ export class CampScene extends Phaser.Scene {
: undefined;
const secondReliefChoiceId =
this.campaign?.campVisitChoiceIds[secondBattleReliefVisitId] ?? null;
const thirdCampPreparation = this.campaign && this.isThirdCampPreparationSlice()
? thirdCampPreparationProgress(this.campaign)
: undefined;
const availableCampDialogues = this.availableCampDialogues();
const selectedCampDialogue = availableCampDialogues.find(
(dialogue) => dialogue.id === this.selectedDialogueId
@@ -25470,6 +25871,63 @@ export class CampScene extends Phaser.Scene {
)
}
: null,
thirdCampPreparation: thirdCampPreparation
? {
available: true,
sourceBattleId: thirdCampPreparation.sourceBattleId,
targetBattleId: thirdCampPreparation.targetBattleId,
selectionRecordId: thirdCampPreparation.selectionRecordId,
ready: thirdCampPreparation.ready,
selectedPriorityId: thirdCampPreparation.selectedPriorityId,
selectedLabel: thirdCampPreparation.selectedLabel,
browserOpen: this.thirdCampPreparationBrowserOpen,
toggleButtonBounds: this.sortieInteractiveObjectBoundsDebug(
this.thirdCampPreparationToggleButton
),
closeButtonBounds: this.sortieInteractiveObjectBoundsDebug(
this.thirdCampPreparationCloseButton
),
priorities: thirdCampPreparation.availability.map((entry) => {
const view = this.thirdCampPreparationCardViews.find(
(candidate) => candidate.priorityId === entry.priority.id
);
return {
id: entry.priority.id,
label: entry.priority.label,
activityLabel: entry.priority.activityLabel,
summary: entry.priority.summary,
selectable: entry.selectable,
selected: thirdCampPreparation.selectedPriorityId === entry.priority.id,
evidenceKind: entry.evidence?.kind ?? null,
evidence: entry.evidence
? entry.evidence.kind === 'priority-return-dialogue'
? {
...entry.evidence,
unitIds: [...entry.evidence.unitIds]
}
: { ...entry.evidence }
: null,
unavailableReason: entry.unavailableReason ?? null,
cardBounds: this.sortieObjectBoundsDebug(view?.background),
actionButtonBounds: this.sortieInteractiveObjectBoundsDebug(
view?.actionButton
)
};
})
}
: {
available: false,
sourceBattleId: thirdCampPreparationSourceBattleId,
targetBattleId: thirdCampPreparationTargetBattleId,
selectionRecordId: null,
ready: false,
selectedPriorityId: null,
selectedLabel: null,
browserOpen: false,
toggleButtonBounds: null,
closeButtonBounds: null,
priorities: []
},
firstPursuitScoutMemory: {
visitId: firstPursuitScoutVisitId,
available: Boolean(firstPursuitScoutVisit),

View File

@@ -9,6 +9,14 @@ import type { UnitDirection } from '../data/unitAssets';
import { equipmentExpToNext, equipmentSlots, type EquipmentSlot } from '../data/battleItems';
import type { SortieOrderId } from '../data/sortieOrders';
import { coreSortieResonanceMinLevel } from '../data/sortieSynergy';
import {
getThirdCampPreparationPriority,
isThirdCampPreparationPriorityId,
thirdCampPreparationInformationEnemyUnitId,
thirdCampPreparationSelectionRecordId,
thirdCampPreparationTargetBattleId,
type ThirdCampPreparationPriorityId
} from '../data/thirdCampPreparation';
export type BattleSaveFaction = 'ally' | 'enemy';
export type BattleSaveRosterTab = 'ally' | 'enemy';
@@ -91,6 +99,19 @@ export type BattleSaveCooperationStats = {
additionalDamage: number;
};
export type BattleSaveThirdCampPreparationState = {
selectionRecordId: typeof thirdCampPreparationSelectionRecordId;
priorityId: ThirdCampPreparationPriorityId;
label: string;
markedEnemyUnitId?: typeof thirdCampPreparationInformationEnemyUnitId;
informationConsumed: boolean;
equipmentConsumed: boolean;
companionAttempts: number;
companionSuccesses: number;
usageCount: number;
preventedDamage: number;
};
export type BattleSaveEventPriority = 'critical' | 'high' | 'normal' | 'low';
export type BattleSavePendingEvent = {
@@ -111,6 +132,7 @@ export type BattleSaveState = {
sortieRecommendation?: CampaignSortieRecommendationSnapshot;
coreResonanceStats?: BattleSaveCoreResonanceStats;
cooperationStatsByBond?: Record<string, BattleSaveCooperationStats>;
thirdCampPreparation?: BattleSaveThirdCampPreparationState;
savedAt: string;
turnNumber: number;
activeFaction: BattleSaveFaction;
@@ -207,6 +229,7 @@ export function normalizeBattleSaveState(
normalizeCoreResonanceSaveFields(cloned, options);
normalizeCooperationSaveFields(cloned, options);
normalizeThirdCampPreparationSaveField(cloned);
const recommendation = normalizeCampaignSortieRecommendationSnapshot(cloned.sortieRecommendation, {
expectedBattleId: typeof cloned.battleId === 'string' ? cloned.battleId : options.expectedBattleId,
allowedUnitIds: options.validAllyUnitIds
@@ -256,6 +279,10 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida
return false;
}
if (!isOptionalThirdCampPreparationState(state.thirdCampPreparation, state)) {
return false;
}
const attackIntents = state.attackIntents;
const units = state.units;
@@ -505,6 +532,141 @@ function isOptionalCooperationStatsByBond(
));
}
function normalizeThirdCampPreparationSaveField(
state: Record<string, unknown>
) {
if (
state.battleId !== thirdCampPreparationTargetBattleId ||
!isRecord(state.thirdCampPreparation)
) {
delete state.thirdCampPreparation;
return;
}
const raw = state.thirdCampPreparation;
const priorityId = isThirdCampPreparationPriorityId(raw.priorityId)
? raw.priorityId
: undefined;
const priority = priorityId
? getThirdCampPreparationPriority(priorityId)
: undefined;
if (
!priorityId ||
!priority ||
raw.selectionRecordId !== thirdCampPreparationSelectionRecordId
) {
delete state.thirdCampPreparation;
return;
}
const informationConsumed =
priorityId === 'information' && raw.informationConsumed === true;
const equipmentConsumed =
priorityId === 'equipment' && raw.equipmentConsumed === true;
const companionAttempts = priorityId === 'companion'
? normalizeCoreResonanceStatValue(raw.companionAttempts)
: 0;
const companionSuccesses = priorityId === 'companion'
? Math.min(
companionAttempts,
normalizeCoreResonanceStatValue(raw.companionSuccesses)
)
: 0;
const usageCount = priorityId === 'information'
? Number(informationConsumed)
: priorityId === 'equipment'
? Number(equipmentConsumed)
: companionAttempts;
const preventedDamage = priorityId === 'equipment' && equipmentConsumed
? normalizeCoreResonanceStatValue(raw.preventedDamage)
: 0;
state.thirdCampPreparation = {
selectionRecordId: thirdCampPreparationSelectionRecordId,
priorityId,
label: priority.label,
...(priorityId === 'information'
? {
markedEnemyUnitId:
thirdCampPreparationInformationEnemyUnitId
}
: {}),
informationConsumed,
equipmentConsumed,
companionAttempts,
companionSuccesses,
usageCount,
preventedDamage
} satisfies BattleSaveThirdCampPreparationState;
}
function isOptionalThirdCampPreparationState(
value: unknown,
state: Record<string, unknown>
) {
if (value === undefined) {
return true;
}
if (
state.battleId !== thirdCampPreparationTargetBattleId ||
!isRecord(value) ||
value.selectionRecordId !==
thirdCampPreparationSelectionRecordId ||
!isThirdCampPreparationPriorityId(value.priorityId)
) {
return false;
}
const priority = getThirdCampPreparationPriority(value.priorityId);
if (
!priority ||
value.label !== priority.label ||
typeof value.informationConsumed !== 'boolean' ||
typeof value.equipmentConsumed !== 'boolean' ||
!isBattleUnitStatValue(value.companionAttempts) ||
!isBattleUnitStatValue(value.companionSuccesses) ||
!isBattleUnitStatValue(value.usageCount) ||
!isBattleUnitStatValue(value.preventedDamage) ||
Number(value.companionSuccesses) >
Number(value.companionAttempts)
) {
return false;
}
if (value.priorityId === 'information') {
return (
value.markedEnemyUnitId ===
thirdCampPreparationInformationEnemyUnitId &&
value.equipmentConsumed === false &&
Number(value.companionAttempts) === 0 &&
Number(value.companionSuccesses) === 0 &&
Number(value.usageCount) ===
Number(value.informationConsumed) &&
Number(value.preventedDamage) === 0
);
}
if (value.priorityId === 'equipment') {
return (
value.markedEnemyUnitId === undefined &&
value.informationConsumed === false &&
Number(value.companionAttempts) === 0 &&
Number(value.companionSuccesses) === 0 &&
Number(value.usageCount) ===
Number(value.equipmentConsumed) &&
(
value.equipmentConsumed === true ||
Number(value.preventedDamage) === 0
)
);
}
return (
value.markedEnemyUnitId === undefined &&
value.informationConsumed === false &&
value.equipmentConsumed === false &&
Number(value.usageCount) ===
Number(value.companionAttempts) &&
Number(value.preventedDamage) === 0
);
}
function isOptionalCoreResonanceState(
state: Record<string, unknown>,
options: BattleSaveValidationOptions

View File

@@ -2,6 +2,14 @@ import { equipmentExpToNext, equipmentSlots, itemCatalog, type EquipmentSlot } f
import { unitClasses } from '../data/battleRules';
import { battleScenarios, type BattleScenarioId } from '../data/battles';
import { findCityStayAfterBattle, type CityStayId } from '../data/cityStays';
import {
isThirdCampPreparationPriorityId,
resolveThirdCampPreparationAvailability,
thirdCampPreparationSelectionRecordId,
thirdCampPreparationSourceBattleId,
thirdCampPreparationTargetBattleId,
type ThirdCampPreparationPriorityId
} from '../data/thirdCampPreparation';
import {
campaignRecruitUnits,
jianYongRecruitBond,
@@ -81,6 +89,12 @@ export type CampaignVictoryRewardAcknowledgements = Partial<Record<string, Campa
export type CampaignCampChoiceHistory = Record<string, string>;
export type ThirdCampPreparationSelectionSnapshot = {
sourceBattleId: typeof thirdCampPreparationSourceBattleId;
targetBattleId: typeof thirdCampPreparationTargetBattleId;
priorityId: ThirdCampPreparationPriorityId;
};
const campaignVictoryRewardCategoryIdSet = new Set<string>(campaignVictoryRewardCategoryIds);
export type CampaignSortieUnitPerformanceSnapshot = {
@@ -527,6 +541,7 @@ export type CampaignState = {
completedCampVisits: string[];
campDialogueChoiceIds: CampaignCampChoiceHistory;
campVisitChoiceIds: CampaignCampChoiceHistory;
thirdCampPreparationSelection?: ThirdCampPreparationSelectionSnapshot;
acknowledgedVictoryRewardBattleIds: string[];
acknowledgedVictoryRewardCategories: CampaignVictoryRewardAcknowledgements;
dismissedVictoryRewardNoticeBattleIds: string[];
@@ -1707,6 +1722,16 @@ function normalizeCampaignState(state: Partial<CampaignState>): CampaignState {
normalized.inventory = normalizeInventory(normalized.inventory);
normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory);
normalized.firstBattleReport = normalizeFirstBattleReport(normalized.firstBattleReport);
delete normalized.campVisitChoiceIds[thirdCampPreparationSelectionRecordId];
normalized.completedCampVisits = normalized.completedCampVisits.filter(
(visitId) => visitId !== thirdCampPreparationSelectionRecordId
);
if (normalized.firstBattleReport) {
normalized.firstBattleReport.completedCampVisits =
normalized.firstBattleReport.completedCampVisits.filter(
(visitId) => visitId !== thirdCampPreparationSelectionRecordId
);
}
normalized.completedCampDialogues = mergeCampCompletionIds(
normalized.completedCampDialogues,
normalized.firstBattleReport?.completedCampDialogues ?? [],
@@ -1852,6 +1877,13 @@ function normalizeCampaignState(state: Partial<CampaignState>): CampaignState {
const available = campaignVictoryRewardCategoriesFor(source);
return available.length > 0 && available.every((category) => acknowledged.has(category));
});
normalized.thirdCampPreparationSelection = normalizeThirdCampPreparationSelection(
normalized.thirdCampPreparationSelection,
normalized
);
if (!normalized.thirdCampPreparationSelection) {
delete normalized.thirdCampPreparationSelection;
}
normalized.sortieOrderHistory = normalizeCampaignSortieOrderHistory(
normalized.sortieOrderHistory,
normalized.battleHistory,
@@ -1904,6 +1936,33 @@ function normalizeCampChoiceHistory(value: unknown): CampaignCampChoiceHistory {
return history;
}
function normalizeThirdCampPreparationSelection(
value: unknown,
campaign: CampaignState
): ThirdCampPreparationSelectionSnapshot | undefined {
if (
!isPlainObject(value) ||
value.sourceBattleId !== thirdCampPreparationSourceBattleId ||
value.targetBattleId !== thirdCampPreparationTargetBattleId ||
!isThirdCampPreparationPriorityId(value.priorityId)
) {
return undefined;
}
const priorityAvailable = resolveThirdCampPreparationAvailability(
campaign
).some(
(entry) =>
entry.priority.id === value.priorityId && entry.selectable
);
return priorityAvailable
? {
sourceBattleId: thirdCampPreparationSourceBattleId,
targetBattleId: thirdCampPreparationTargetBattleId,
priorityId: value.priorityId
}
: undefined;
}
function uniqueCampHistoryIds(value: unknown): string[] {
return [
...new Set(

View File

@@ -0,0 +1,210 @@
import {
getThirdCampPreparationPriority,
hasThirdCampPreparationSourceVictory,
isThirdCampPreparationPriorityId,
resolveThirdCampPreparationAvailability,
resolveThirdCampPreparationMemory,
resolveThirdCampPreparationSelection,
thirdCampPreparationSelectionRecordId,
thirdCampPreparationSourceBattleId,
thirdCampPreparationTargetBattleId,
type ThirdCampPreparationAvailability,
type ThirdCampPreparationMemory,
type ThirdCampPreparationPriorityDefinition,
type ThirdCampPreparationPriorityId
} from '../data/thirdCampPreparation';
import {
getCampaignState,
setCampaignState,
type CampaignState
} from './campaignState';
export type ThirdCampPreparationSelectionFailure =
| 'invalid-campaign'
| 'invalid-priority'
| 'activity-required'
| 'save-unavailable';
export type ThirdCampPreparationSelectionResult =
| {
ok: true;
priority: ThirdCampPreparationPriorityDefinition;
memory: ThirdCampPreparationMemory;
campaign: CampaignState;
changed: boolean;
}
| {
ok: false;
reason: ThirdCampPreparationSelectionFailure;
};
export type ThirdCampPreparationClearResult =
| {
ok: true;
campaign: CampaignState;
changed: boolean;
}
| {
ok: false;
reason: 'invalid-campaign' | 'save-unavailable';
};
export function thirdCampPreparationProgress(
campaign: CampaignState = getCampaignState()
) {
const availability = resolveThirdCampPreparationAvailability(
campaign
);
const selection = resolveThirdCampPreparationSelection(campaign);
return {
sourceBattleId: thirdCampPreparationSourceBattleId,
targetBattleId: thirdCampPreparationTargetBattleId,
selectionRecordId: thirdCampPreparationSelectionRecordId,
availability,
selectedPriorityId: selection?.priorityId ?? null,
selectedLabel: selection?.label ?? null,
ready: selection?.selectable === true
};
}
export function setThirdCampPreparationPriority(
priorityId: string
): ThirdCampPreparationSelectionResult {
let snapshot: CampaignState;
try {
snapshot = getCampaignState();
} catch {
return { ok: false, reason: 'save-unavailable' };
}
if (!campaignSupportsThirdCampPreparation(snapshot)) {
return { ok: false, reason: 'invalid-campaign' };
}
if (!isThirdCampPreparationPriorityId(priorityId)) {
return { ok: false, reason: 'invalid-priority' };
}
const availability = availabilityFor(snapshot, priorityId);
if (!availability?.selectable) {
return { ok: false, reason: 'activity-required' };
}
const priority = getThirdCampPreparationPriority(priorityId);
if (!priority) {
return { ok: false, reason: 'invalid-priority' };
}
const previousPriorityId =
snapshot.thirdCampPreparationSelection?.priorityId;
if (previousPriorityId === priorityId) {
const memory = resolveThirdCampPreparationMemory({
campaign: snapshot,
battleId: thirdCampPreparationTargetBattleId
});
return memory
? {
ok: true,
priority,
memory,
campaign: snapshot,
changed: false
}
: { ok: false, reason: 'activity-required' };
}
const updated: CampaignState = {
...snapshot,
thirdCampPreparationSelection: {
sourceBattleId: thirdCampPreparationSourceBattleId,
targetBattleId: thirdCampPreparationTargetBattleId,
priorityId
}
};
const persisted = persistSelection(updated, snapshot);
if (!persisted) {
return { ok: false, reason: 'save-unavailable' };
}
const memory = resolveThirdCampPreparationMemory({
campaign: persisted,
battleId: thirdCampPreparationTargetBattleId
});
if (!memory || memory.priorityId !== priorityId) {
restoreCampaignSnapshot(snapshot);
return { ok: false, reason: 'save-unavailable' };
}
return {
ok: true,
priority,
memory,
campaign: persisted,
changed: true
};
}
export function clearThirdCampPreparationPriority():
ThirdCampPreparationClearResult {
let snapshot: CampaignState;
try {
snapshot = getCampaignState();
} catch {
return { ok: false, reason: 'save-unavailable' };
}
if (!campaignSupportsThirdCampPreparation(snapshot)) {
return { ok: false, reason: 'invalid-campaign' };
}
if (!snapshot.thirdCampPreparationSelection) {
return {
ok: true,
campaign: snapshot,
changed: false
};
}
const updated: CampaignState = {
...snapshot
};
delete updated.thirdCampPreparationSelection;
const persisted = persistSelection(updated, snapshot);
return persisted
? { ok: true, campaign: persisted, changed: true }
: { ok: false, reason: 'save-unavailable' };
}
function campaignSupportsThirdCampPreparation(
campaign: CampaignState
) {
return (
campaign.step === 'third-camp' &&
campaign.latestBattleId === thirdCampPreparationSourceBattleId &&
hasThirdCampPreparationSourceVictory(campaign)
);
}
function availabilityFor(
campaign: CampaignState,
priorityId: ThirdCampPreparationPriorityId
): ThirdCampPreparationAvailability | undefined {
return resolveThirdCampPreparationAvailability(campaign).find(
(entry) => entry.priority.id === priorityId
);
}
function persistSelection(
updated: CampaignState,
snapshot: CampaignState
) {
try {
return setCampaignState(updated);
} catch {
restoreCampaignSnapshot(snapshot);
return undefined;
}
}
function restoreCampaignSnapshot(snapshot: CampaignState) {
try {
setCampaignState(snapshot);
} catch {
// setCampaignState restores the in-memory snapshot before persistence.
}
}