feat: carry third-camp preparation into battle
This commit is contained in:
639
src/game/data/thirdCampPreparation.ts
Normal file
639
src/game/data/thirdCampPreparation.ts
Normal 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
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
210
src/game/state/thirdCampPreparationActions.ts
Normal file
210
src/game/state/thirdCampPreparationActions.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user