Files
heros_web/src/game/data/thirdCampPreparation.ts

867 lines
24 KiB
TypeScript

import {
resolveThirdBattlePriorityReturnDialogue,
thirdBattleReturnSourceBattleId
} from './thirdBattleReturnDialogue';
import {
completedThirdCampExplorationActivityIds,
thirdCampExplorationVisitId,
type ThirdCampExplorationActivityId
} from './thirdCampExploration';
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턴 아군의 첫 피격 피해를 한 번 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: 'exploration-activity';
battleId: typeof thirdCampPreparationSourceBattleId;
visitId: typeof thirdCampExplorationVisitId;
activityId: Exclude<
ThirdCampExplorationActivityId,
'companion'
>;
}
| {
kind: 'exploration-activity';
battleId: typeof thirdCampPreparationSourceBattleId;
visitId: typeof thirdCampExplorationVisitId;
activityId: 'companion';
dialogueId: string;
bondId: string;
unitIds: [string, string];
}
| {
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'
| 'incompatible';
text: string;
activeThroughTurn: typeof thirdCampPreparationActiveThroughTurn;
turnNumber: number | null;
usageCount: number;
};
export type ThirdCampPreparationCompatibility = {
compatible: boolean;
requiredUnitIds: string[];
missingUnitIds: string[];
disabledReason: 'required-units-missing' | null;
};
export type ThirdCampPreparationCampaignState = {
step?: unknown;
latestBattleId?: unknown;
battleHistory?: Readonly<
Record<string, ThirdCampPreparationBattleRecord | null | undefined>
>;
firstBattleReport?: ThirdCampPreparationBattleRecord | null;
completedCampDialogues?: readonly unknown[];
completedCampVisits?: readonly unknown[];
thirdCampPreparationSelection?: {
sourceBattleId?: unknown;
targetBattleId?: unknown;
priorityId?: unknown;
confirmed?: 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 =
completedExplorationEvidence(campaign, 'information') ??
selectedLegacyRewardEvidence(campaign, 'information', 'unlocks');
const equipmentEvidence =
completedExplorationEvidence(campaign, 'equipment') ??
selectedLegacyRewardEvidence(
campaign,
'equipment',
'equipment'
);
const companionEvidence =
completedExplorationEvidence(campaign, 'companion') ??
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,
confirmed: selection.confirmed === true,
...(availability.evidence
? { evidence: cloneEvidence(availability.evidence) }
: {}),
...(availability.unavailableReason
? { unavailableReason: availability.unavailableReason }
: {})
}
: undefined;
}
export function shouldReviewThirdCampPreparationBeforeRetry(
campaign?: ThirdCampPreparationCampaignState | null
) {
const selection =
resolveThirdCampPreparationSelection(campaign);
if (
campaign?.step !== 'fourth-battle' ||
!selection?.selectable ||
selection.confirmed
) {
return false;
}
const targetSettlement =
campaign.battleHistory?.[
thirdCampPreparationTargetBattleId
];
return (
targetSettlement?.outcome === 'defeat' ||
(campaign.firstBattleReport?.battleId ===
thirdCampPreparationTargetBattleId &&
campaign.firstBattleReport.outcome === 'defeat')
);
}
export function resolveThirdCampPreparationMemory(options: {
campaign?: ThirdCampPreparationCampaignState | null;
battleId?: string;
}): ThirdCampPreparationMemory | undefined {
return resolveThirdCampPreparationMemoryCandidate(options);
}
export function resolveThirdCampPreparationBattleResumeMemory(
options: {
campaign?: ThirdCampPreparationCampaignState | null;
battleId?: string;
savedPriorityId?: unknown;
}
): ThirdCampPreparationMemory | undefined {
if (!isThirdCampPreparationPriorityId(options.savedPriorityId)) {
return undefined;
}
return resolveThirdCampPreparationMemoryCandidate(
options,
options.savedPriorityId
);
}
function resolveThirdCampPreparationMemoryCandidate(
options: {
campaign?: ThirdCampPreparationCampaignState | null;
battleId?: string;
},
resumedPriorityId?: ThirdCampPreparationPriorityId
): ThirdCampPreparationMemory | undefined {
if (options.battleId !== thirdCampPreparationTargetBattleId) {
return undefined;
}
const selection = resolveThirdCampPreparationSelection(
options.campaign
);
if (
!selection?.selectable ||
!selection.evidence ||
(!selection.confirmed &&
resumedPriorityId !== selection.priorityId)
) {
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;
compatibility?: ThirdCampPreparationCompatibility;
}): 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') {
if (options.compatibility?.compatible === false) {
return presentationBase(
options.memory,
'incompatible',
`${options.memory.label} · 필요한 공명조가 편성되지 않아 이번 전투에는 미적용`,
null,
usageCount
);
}
return presentationBase(
options.memory,
'deployment',
options.memory.deploymentLine,
null,
usageCount
);
}
if (options.stage === 'turn') {
const turnNumber = positiveInteger(options.turnNumber);
if (!turnNumber) {
return undefined;
}
if (turnNumber > thirdCampPreparationActiveThroughTurn) {
return presentationBase(
options.memory,
'expired',
`${options.memory.label} · 준비 효과 종료`,
turnNumber,
usageCount
);
}
if (options.compatibility?.compatible === false) {
return presentationBase(
options.memory,
'incompatible',
`${options.memory.label} · 필요한 공명조가 편성되지 않아 이번 전투에는 미적용`,
turnNumber,
usageCount
);
}
return presentationBase(
options.memory,
'active',
`${options.memory.activeLine} · ${turnNumber}/${thirdCampPreparationActiveThroughTurn}`,
turnNumber,
usageCount
);
}
if (options.compatibility?.compatible === false) {
return presentationBase(
options.memory,
'incompatible',
`${options.memory.label} · 필요한 공명조가 편성되지 않아 이번 전투에는 미적용`,
null,
usageCount
);
}
const applied = usageCount > 0;
return presentationBase(
options.memory,
applied ? 'applied' : 'unused',
applied
? `${options.memory.appliedLine} · ${usageCount}`
: options.memory.unusedLine,
null,
usageCount
);
}
export function evaluateThirdCampPreparationCompatibility(
memory: ThirdCampPreparationMemory | undefined,
selectedUnitIds: readonly string[]
): ThirdCampPreparationCompatibility {
const requiredUnitIds =
memory?.effect.kind === 'return-bond-opening'
? [...memory.effect.unitIds]
: [];
const selected = new Set(selectedUnitIds);
const missingUnitIds = requiredUnitIds.filter(
(unitId) => !selected.has(unitId)
);
return {
compatible: missingUnitIds.length === 0,
requiredUnitIds,
missingUnitIds,
disabledReason:
missingUnitIds.length > 0 ? 'required-units-missing' : null
};
}
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 selectedLegacyRewardEvidence(
campaign: ThirdCampPreparationCampaignState | null | undefined,
priorityId: Extract<
ThirdCampPreparationPriorityId,
'information' | 'equipment'
>,
category: 'unlocks' | 'equipment'
) {
const selection = campaign?.thirdCampPreparationSelection;
if (
selection?.sourceBattleId !==
thirdCampPreparationSourceBattleId ||
selection.targetBattleId !==
thirdCampPreparationTargetBattleId ||
selection.priorityId !== priorityId
) {
return undefined;
}
return acknowledgedRewardEvidence(campaign, category);
}
function completedExplorationEvidence(
campaign: ThirdCampPreparationCampaignState | null | undefined,
activityId: ThirdCampExplorationActivityId
): Extract<
ThirdCampPreparationEvidence,
{ kind: 'exploration-activity' }
> | undefined {
const completedActivityIds =
completedThirdCampExplorationActivityIds(
arrayValue(campaign?.completedCampVisits).filter(
(visitId): visitId is string => typeof visitId === 'string'
)
);
if (!completedActivityIds.includes(activityId)) {
return undefined;
}
if (activityId !== 'companion') {
return {
kind: 'exploration-activity',
battleId: thirdCampPreparationSourceBattleId,
visitId: thirdCampExplorationVisitId,
activityId
};
}
const dialogue =
resolveThirdBattlePriorityReturnDialogue(campaign);
const bond = dialogue
? companionBondByDialogueId[dialogue.targetDialogueId]
: undefined;
return dialogue && bond
? {
kind: 'exploration-activity',
battleId: thirdCampPreparationSourceBattleId,
visitId: thirdCampExplorationVisitId,
activityId: 'companion',
dialogueId: dialogue.targetDialogueId,
bondId: bond.bondId,
unitIds: [...bond.unitIds]
}
: 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 === 'exploration-activity' &&
evidence.activityId === 'information') ||
(evidence.kind === 'victory-reward-category' &&
evidence.category === 'unlocks')
)
) {
return { ...effect };
}
if (
effect.kind === 'prepared-equipment-guard' &&
(
(evidence.kind === 'exploration-activity' &&
evidence.activityId === 'equipment') ||
(evidence.kind === 'victory-reward-category' &&
evidence.category === 'equipment')
)
) {
return { ...effect };
}
if (
effect.kind === 'return-bond-opening' &&
(
evidence.kind === 'priority-return-dialogue' ||
(evidence.kind === 'exploration-activity' &&
evidence.activityId === 'companion')
)
) {
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' ||
(evidence.kind === 'exploration-activity' &&
evidence.activityId === 'companion')
) {
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 : [];
}