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

@@ -187,9 +187,28 @@ RPG로 다듬는다.
다시 군영에 들어와도 완료 상태를 유지한다.
- 네 가지 목표 조합과 Canvas·WebGL의 1920x1080 진행을 자동 검증한다.
다음 초반 묶음 세 번째 군영에서 정보 수집, 장비 정비, 동료 대화 가운데
이번 출진의 준비 우선순위 하나를 직접 정하고, 그 선택이 네 번째 전투
브리핑과 첫 3턴 안에 짧고 명확하게 되돌아오게 한다.
### 완료한 9차 묶음: 세 번째 군영의 준비 우선순위와 네 번째 전투 회수
- 세 번째 승리 뒤 군영에서 `본영 정보`, `장비 정비`, `동료 공명` 가운데
이번 출진의 준비 우선순위 하나를 직접 정한다.
- 승전 보고의 정보·장비 범주를 실제로 확인했거나 결과별 우선 귀환 대화를
완료한 항목만 선택할 수 있어, 보지 않은 활동을 완료한 것처럼 취급하지
않는다.
- 본영 정보는 배치 때 표식 선봉의 첫 행동을 보여 주고 첫 3턴 안 해당
대상 첫 공격의 명중을 한 번 `+10%p`, 장비 정비는 같은 기간 아군의 첫
피격 피해를 한 번 `-8%` 보정한다.
- 동료 공명은 우선 귀환 대화에 참여한 정확한 공명조에만 첫 3턴 협공
확률 `+5%p`를 적용하며, 다른 조합에는 옮겨 붙지 않는다.
- 선택부터 네 번째 전투의 배치·턴·결과까지 같은 `selectionRecordId`
항목명을 사용하고, 결과에서 실제 발동 여부와 횟수를 되돌려 보여 준다.
- 전투 저장에는 발동 소비 여부, 공명 시도·성공 횟수와 막은 피해를 함께
보존해 재개해도 효과가 되살아나거나 중복 적용되지 않게 한다.
- 전투 장면을 연속으로 재사용해도 이전 전투의 턴과 진영이 남지 않도록
매 전투를 `1턴 · 아군`에서 초기화한다.
다음 묶음은 서주 체류에서 장비를 구매한 뒤 실제 무장에게 장착하고, 그
선택을 여덟 번째 전투의 출진 전 비교·전투 수치·결과 피드백까지 같은
장비 기록으로 이어지게 한다.
## 장기 개선 순서

View File

@@ -44,6 +44,8 @@
"verify:second-battle-relief:browser": "node scripts/verify-second-battle-relief-exploration-browser.mjs",
"verify:third-battle-return": "node scripts/verify-third-battle-return-dialogue.mjs",
"verify:third-battle-return:browser": "node scripts/verify-third-battle-priority-return-browser.mjs",
"verify:third-camp-preparation": "node scripts/verify-third-camp-preparation.mjs",
"verify:third-camp-preparation:browser": "node scripts/verify-third-camp-preparation-browser.mjs",
"verify:prologue-exploration-assets": "node scripts/verify-prologue-exploration-asset-data.mjs",
"verify:exploration-backgrounds": "node scripts/verify-prologue-exploration-asset-data.mjs",
"verify:exploration-characters": "node scripts/verify-exploration-character-asset-data.mjs",
@@ -75,7 +77,7 @@
"verify:interaction-ux": "node scripts/verify-interaction-ux.mjs",
"verify:save-flow": "node scripts/verify-save-retry-flow.mjs",
"verify:release": "node scripts/verify-release-candidate.mjs",
"verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:city-stays:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:second-battle-relief:browser && pnpm run verify:third-battle-return:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance",
"verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:city-stays:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:second-battle-relief:browser && pnpm run verify:third-battle-return:browser && pnpm run verify:third-camp-preparation:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance",
"verify:1.0": "pnpm run verify:local-release && pnpm run verify:flow && pnpm run qa:campaign-complete && pnpm run qa:battle-maps:webgl",
"verify:public-deploy": "node scripts/verify-public-deploy.mjs",
"qa:representative": "node scripts/qa-representative-battles.mjs",

View File

@@ -10,6 +10,12 @@ try {
const { isValidBattleSaveState, normalizeBattleSaveSlot, normalizeBattleSaveState, parseBattleSaveState } = await server.ssrLoadModule(
'/src/game/state/battleSaveState.ts'
);
const {
getThirdCampPreparationPriority,
thirdCampPreparationInformationEnemyUnitId,
thirdCampPreparationSelectionRecordId,
thirdCampPreparationTargetBattleId
} = await server.ssrLoadModule('/src/game/data/thirdCampPreparation.ts');
const options = {
expectedBattleId: 'first-battle-zhuo-commandery',
@@ -210,6 +216,196 @@ try {
}, options),
'Expected direct validation to reject unsafe core resonance totals before normalization.'
);
const fourthBattleOptions = {
...options,
expectedBattleId: thirdCampPreparationTargetBattleId
};
const fourthBattleBaseState = {
...validState,
battleId: thirdCampPreparationTargetBattleId,
campaignStep: 'fourth-battle',
enemyUsableUseKeys: []
};
const thirdPreparationLabels = Object.fromEntries(
['information', 'equipment', 'companion'].map((priorityId) => [
priorityId,
getThirdCampPreparationPriority(priorityId)?.label
])
);
const validThirdPreparationStates = [
{
selectionRecordId: thirdCampPreparationSelectionRecordId,
priorityId: 'information',
label: thirdPreparationLabels.information,
markedEnemyUnitId: thirdCampPreparationInformationEnemyUnitId,
informationConsumed: true,
equipmentConsumed: false,
companionAttempts: 0,
companionSuccesses: 0,
usageCount: 1,
preventedDamage: 0
},
{
selectionRecordId: thirdCampPreparationSelectionRecordId,
priorityId: 'equipment',
label: thirdPreparationLabels.equipment,
informationConsumed: false,
equipmentConsumed: true,
companionAttempts: 0,
companionSuccesses: 0,
usageCount: 1,
preventedDamage: 8
},
{
selectionRecordId: thirdCampPreparationSelectionRecordId,
priorityId: 'companion',
label: thirdPreparationLabels.companion,
informationConsumed: false,
equipmentConsumed: false,
companionAttempts: 3,
companionSuccesses: 2,
usageCount: 3,
preventedDamage: 0
}
];
validThirdPreparationStates.forEach((thirdCampPreparation) => {
const state = { ...fourthBattleBaseState, thirdCampPreparation };
const parsed = parseBattleSaveState(JSON.stringify(state), fourthBattleOptions);
assert(
isValidBattleSaveState(state, fourthBattleOptions) &&
JSON.stringify(parsed?.thirdCampPreparation) === JSON.stringify(thirdCampPreparation) &&
parsed?.thirdCampPreparation !== thirdCampPreparation,
`Expected ${thirdCampPreparation.priorityId} preparation progress to round-trip as a deep-cloned save field.`
);
});
const normalizedWrongBattlePreparation = parseBattleSaveState(JSON.stringify({
...validState,
thirdCampPreparation: validThirdPreparationStates[0]
}), options);
assert(
normalizedWrongBattlePreparation &&
normalizedWrongBattlePreparation.thirdCampPreparation === undefined &&
!isValidBattleSaveState(
{ ...validState, thirdCampPreparation: validThirdPreparationStates[0] },
options
),
'Expected preparation progress attached to a different battle to be discarded and rejected by direct validation.'
);
const normalizedUnknownPreparationPriority = parseBattleSaveState(JSON.stringify({
...fourthBattleBaseState,
thirdCampPreparation: {
...validThirdPreparationStates[0],
priorityId: 'unknown-priority'
}
}), fourthBattleOptions);
const normalizedWrongPreparationRecord = parseBattleSaveState(JSON.stringify({
...fourthBattleBaseState,
thirdCampPreparation: {
...validThirdPreparationStates[0],
selectionRecordId: 'forged-preparation-record'
}
}), fourthBattleOptions);
assert(
normalizedUnknownPreparationPriority?.thirdCampPreparation === undefined &&
normalizedWrongPreparationRecord?.thirdCampPreparation === undefined,
'Expected unknown priorities and forged preparation record ids to be removed without rejecting the battle save.'
);
const normalizedInformationPreparation = parseBattleSaveState(JSON.stringify({
...fourthBattleBaseState,
thirdCampPreparation: {
...validThirdPreparationStates[0],
label: 'forged-label',
markedEnemyUnitId: 'forged-vanguard',
informationConsumed: false,
equipmentConsumed: true,
companionAttempts: 7,
companionSuccesses: 6,
usageCount: 999,
preventedDamage: 90
}
}), fourthBattleOptions)?.thirdCampPreparation;
assert(
normalizedInformationPreparation?.label === thirdPreparationLabels.information &&
normalizedInformationPreparation.markedEnemyUnitId === thirdCampPreparationInformationEnemyUnitId &&
normalizedInformationPreparation.informationConsumed === false &&
normalizedInformationPreparation.equipmentConsumed === false &&
normalizedInformationPreparation.companionAttempts === 0 &&
normalizedInformationPreparation.companionSuccesses === 0 &&
normalizedInformationPreparation.usageCount === 0 &&
normalizedInformationPreparation.preventedDamage === 0,
`Expected information preparation to restore the canonical mark and derive its unconsumed state: ${JSON.stringify(normalizedInformationPreparation)}`
);
const normalizedUnusedEquipmentPreparation = parseBattleSaveState(JSON.stringify({
...fourthBattleBaseState,
thirdCampPreparation: {
...validThirdPreparationStates[1],
markedEnemyUnitId: thirdCampPreparationInformationEnemyUnitId,
equipmentConsumed: false,
usageCount: 55,
preventedDamage: 55
}
}), fourthBattleOptions)?.thirdCampPreparation;
const normalizedConsumedEquipmentPreparation = parseBattleSaveState(JSON.stringify({
...fourthBattleBaseState,
thirdCampPreparation: {
...validThirdPreparationStates[1],
markedEnemyUnitId: thirdCampPreparationInformationEnemyUnitId,
usageCount: 0,
preventedDamage: Number.MAX_SAFE_INTEGER
}
}), fourthBattleOptions)?.thirdCampPreparation;
assert(
normalizedUnusedEquipmentPreparation?.markedEnemyUnitId === undefined &&
normalizedUnusedEquipmentPreparation?.equipmentConsumed === false &&
normalizedUnusedEquipmentPreparation?.usageCount === 0 &&
normalizedUnusedEquipmentPreparation?.preventedDamage === 0 &&
normalizedConsumedEquipmentPreparation?.markedEnemyUnitId === undefined &&
normalizedConsumedEquipmentPreparation?.equipmentConsumed === true &&
normalizedConsumedEquipmentPreparation?.usageCount === 1 &&
normalizedConsumedEquipmentPreparation?.preventedDamage === 999999,
'Expected equipment preparation to remove unrelated marks, derive consumption count, and clamp prevented damage.'
);
const normalizedOversizedCompanionPreparation = parseBattleSaveState(JSON.stringify({
...fourthBattleBaseState,
thirdCampPreparation: {
...validThirdPreparationStates[2],
markedEnemyUnitId: thirdCampPreparationInformationEnemyUnitId,
informationConsumed: true,
equipmentConsumed: true,
companionAttempts: Number.MAX_SAFE_INTEGER,
companionSuccesses: Number.MAX_SAFE_INTEGER,
usageCount: 1,
preventedDamage: 1234
}
}), fourthBattleOptions)?.thirdCampPreparation;
assert(
normalizedOversizedCompanionPreparation?.markedEnemyUnitId === undefined &&
normalizedOversizedCompanionPreparation?.informationConsumed === false &&
normalizedOversizedCompanionPreparation?.equipmentConsumed === false &&
normalizedOversizedCompanionPreparation?.companionAttempts === 999999 &&
normalizedOversizedCompanionPreparation?.companionSuccesses === 999999 &&
normalizedOversizedCompanionPreparation?.usageCount === 999999 &&
normalizedOversizedCompanionPreparation?.preventedDamage === 0,
`Expected companion preparation counters to clamp and unrelated consumption fields to clear: ${JSON.stringify(normalizedOversizedCompanionPreparation)}`
);
const { thirdCampPreparation: _legacyPreparation, ...legacyFourthBattleState } = {
...fourthBattleBaseState,
thirdCampPreparation: validThirdPreparationStates[0]
};
const parsedLegacyFourthBattleState = parseBattleSaveState(
JSON.stringify(legacyFourthBattleState),
fourthBattleOptions
);
assert(
isValidBattleSaveState(legacyFourthBattleState, fourthBattleOptions) &&
parsedLegacyFourthBattleState?.thirdCampPreparation === undefined,
'Expected legacy fourth-battle saves without preparation progress to remain valid.'
);
const validSortieStatsState = {
...validState,
battleStats: {

View File

@@ -28,6 +28,7 @@ const checks = [
'scripts/verify-first-pursuit-scout-memory.mjs',
'scripts/verify-second-battle-relief-exploration.mjs',
'scripts/verify-third-battle-return-dialogue.mjs',
'scripts/verify-third-camp-preparation.mjs',
'scripts/verify-prologue-exploration-asset-data.mjs',
'scripts/verify-exploration-character-asset-data.mjs',
'scripts/verify-prologue-dialogue-portrait-data.mjs',

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,963 @@
import assert from 'node:assert/strict';
import { createServer } from 'vite';
const storage = new Map();
let rejectStorageWrites = false;
globalThis.window = {
localStorage: {
getItem(key) {
return storage.has(key) ? storage.get(key) : null;
},
setItem(key, value) {
if (rejectStorageWrites) {
throw new Error('Simulated campaign storage write failure.');
}
storage.set(key, String(value));
},
removeItem(key) {
storage.delete(key);
},
clear() {
storage.clear();
}
}
};
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true, hmr: false },
appType: 'custom'
});
try {
const dataModule = await server.ssrLoadModule(
'/src/game/data/thirdCampPreparation.ts'
);
const actionModule = await server.ssrLoadModule(
'/src/game/state/thirdCampPreparationActions.ts'
);
const campaignModule = await server.ssrLoadModule(
'/src/game/state/campaignState.ts'
);
const { battleScenarios } = await server.ssrLoadModule(
'/src/game/data/battles.ts'
);
verifyCanonicalDefinitions(dataModule, battleScenarios);
verifyActivityGates(dataModule);
verifyTargetBattleMemoryIsolation(dataModule);
verifyBattleLifecycleContract(dataModule);
verifyGuardedPersistentOverwrite(
dataModule,
actionModule,
campaignModule,
battleScenarios
);
verifyOldSaveAndPollutionSafety(
dataModule,
actionModule,
campaignModule,
battleScenarios
);
console.log(
'Third-camp preparation verification passed (persistent activity gates, guarded overwrite, fourth-battle isolation, three-turn lifecycle, applied/unused evaluation, and old-save pollution safety).'
);
} finally {
await server.close();
}
function verifyCanonicalDefinitions(dataModule, battleScenarios) {
const source =
battleScenarios[dataModule.thirdCampPreparationSourceBattleId];
const target =
battleScenarios[dataModule.thirdCampPreparationTargetBattleId];
assert.equal(source?.id, 'third-battle-guangzong-road');
assert.equal(target?.id, 'fourth-battle-guangzong-camp');
assert.equal(
dataModule.thirdCampPreparationSelectionRecordId,
'third-camp-preparation-priority'
);
assert.equal(dataModule.thirdCampPreparationActiveThroughTurn, 3);
assert.deepEqual(
dataModule.thirdCampPreparationPriorityIds,
['information', 'equipment', 'companion']
);
const definitions = dataModule.thirdCampPreparationPriorities;
assert.equal(definitions.length, 3);
assert.equal(new Set(definitions.map(({ id }) => id)).size, 3);
assert.equal(new Set(definitions.map(({ label }) => label)).size, 3);
definitions.forEach((definition) => {
assert.equal(
definition.effect.activeThroughTurn,
dataModule.thirdCampPreparationActiveThroughTurn
);
assert(definition.summary.length > 20);
assert(definition.deploymentLine.includes(definition.label));
assert(definition.activeLine.includes(definition.label));
assert(definition.appliedLine.includes(definition.label));
assert(definition.unusedLine.includes(definition.label));
});
const information =
dataModule.getThirdCampPreparationPriority('information');
assert.equal(information?.effect.kind, 'marked-vanguard-intel');
assert.equal(information?.effect.deploymentPreviewCount, 1);
assert.equal(information?.effect.accuracyBonus, 10);
assert.equal(information?.effect.maxBattleTriggers, 1);
assert.equal(
information?.effect.trackedEnemyUnitId,
dataModule.thirdCampPreparationInformationEnemyUnitId
);
assert(
target.units.some(
(unit) =>
unit.id ===
dataModule.thirdCampPreparationInformationEnemyUnitId &&
unit.faction === 'enemy'
),
'Information preparation must track a canonical fourth-battle enemy.'
);
const equipment =
dataModule.getThirdCampPreparationPriority('equipment');
assert.equal(equipment?.effect.kind, 'prepared-equipment-guard');
assert.equal(equipment?.effect.incomingDamageReductionPercent, 8);
assert.equal(equipment?.effect.maxBattleTriggers, 1);
assert.equal(equipment?.effect.targetRule, 'first-allied-hit');
const companion =
dataModule.getThirdCampPreparationPriority('companion');
assert.equal(companion?.effect.kind, 'return-bond-opening');
assert.equal(companion?.effect.cooperationRateBonus, 5);
const cloned =
dataModule.getThirdCampPreparationPriority('information');
cloned.label = 'mutated';
cloned.effect.accuracyBonus = 99;
const fresh =
dataModule.getThirdCampPreparationPriority('information');
assert.equal(fresh.label, '본영 정보');
assert.equal(fresh.effect.accuracyBonus, 10);
}
function verifyActivityGates(dataModule) {
const base = literalCampaign(dataModule);
let availability =
dataModule.resolveThirdCampPreparationAvailability(base);
assert.deepEqual(
availability.map(({ selectable, unavailableReason }) => ({
selectable,
unavailableReason
})),
[
{
selectable: false,
unavailableReason: 'report-review-required'
},
{
selectable: false,
unavailableReason: 'equipment-change-required'
},
{
selectable: false,
unavailableReason: 'priority-dialogue-required'
}
]
);
const acknowledged = literalCampaign(dataModule, {
acknowledgedCategories: ['unlocks', 'equipment']
});
availability =
dataModule.resolveThirdCampPreparationAvailability(acknowledged);
assert.equal(availability[0].selectable, true);
assert.deepEqual(availability[0].evidence, {
kind: 'victory-reward-category',
battleId: dataModule.thirdCampPreparationSourceBattleId,
category: 'unlocks'
});
assert.equal(availability[1].selectable, true);
assert.deepEqual(availability[1].evidence, {
kind: 'victory-reward-category',
battleId: dataModule.thirdCampPreparationSourceBattleId,
category: 'equipment'
});
assert.equal(availability[2].selectable, false);
const wrongBattleCategories = literalCampaign(dataModule);
wrongBattleCategories.acknowledgedVictoryRewardCategories = {
'fourth-battle-guangzong-camp': ['unlocks', 'equipment']
};
availability =
dataModule.resolveThirdCampPreparationAvailability(
wrongBattleCategories
);
assert.equal(availability[0].selectable, false);
assert.equal(availability[1].selectable, false);
const companion = literalCampaign(dataModule, {
completePriorityDialogue: true
});
availability =
dataModule.resolveThirdCampPreparationAvailability(companion);
const companionAvailability = availability.find(
({ priority }) => priority.id === 'companion'
);
assert.equal(companionAvailability?.selectable, true);
assert.equal(
companionAvailability?.evidence?.kind,
'priority-return-dialogue'
);
assert.equal(
companionAvailability?.evidence?.dialogueId,
'guan-zhang-after-guangzong-road'
);
assert.equal(
companionAvailability?.evidence?.bondId,
'guan-yu__zhang-fei'
);
const unrelatedDialogue = literalCampaign(dataModule);
unrelatedDialogue.completedCampDialogues = [
'liu-guan-after-guangzong-road'
];
availability =
dataModule.resolveThirdCampPreparationAvailability(
unrelatedDialogue
);
assert.equal(
availability.find(({ priority }) => priority.id === 'companion')
?.selectable,
false,
'Only the result-selected priority return dialogue may unlock companion preparation.'
);
const noVictory = literalCampaign(dataModule);
noVictory.battleHistory[
dataModule.thirdCampPreparationSourceBattleId
].outcome = 'defeat';
availability =
dataModule.resolveThirdCampPreparationAvailability(noVictory);
assert(
availability.every(
({ selectable, unavailableReason }) =>
selectable === false &&
unavailableReason === 'battle-record-required'
)
);
}
function verifyTargetBattleMemoryIsolation(dataModule) {
dataModule.thirdCampPreparationPriorityIds.forEach((priorityId) => {
const campaign = eligibleLiteralCampaign(
dataModule,
priorityId
);
const memory = dataModule.resolveThirdCampPreparationMemory({
campaign,
battleId: dataModule.thirdCampPreparationTargetBattleId
});
assert.equal(memory?.priorityId, priorityId);
assert.equal(
memory?.sourceBattleId,
dataModule.thirdCampPreparationSourceBattleId
);
assert.equal(
memory?.targetBattleId,
dataModule.thirdCampPreparationTargetBattleId
);
assert.equal(
memory?.selectionRecordId,
dataModule.thirdCampPreparationSelectionRecordId
);
assert.equal(
dataModule.resolveThirdCampPreparationMemory({
campaign,
battleId: dataModule.thirdCampPreparationSourceBattleId
}),
undefined
);
assert.equal(
dataModule.resolveThirdCampPreparationMemory({
campaign,
battleId: 'fifth-battle-sishui-vanguard'
}),
undefined
);
});
const forged = eligibleLiteralCampaign(dataModule, 'information');
forged.thirdCampPreparationSelection.priorityId = 'forged';
assert.equal(
dataModule.resolveThirdCampPreparationMemory({
campaign: forged,
battleId: dataModule.thirdCampPreparationTargetBattleId
}),
undefined
);
const stale = eligibleLiteralCampaign(dataModule, 'information');
stale.acknowledgedVictoryRewardCategories = {};
assert.equal(
dataModule.resolveThirdCampPreparationMemory({
campaign: stale,
battleId: dataModule.thirdCampPreparationTargetBattleId
}),
undefined,
'A stored choice without its persistent activity evidence must not create a battle effect.'
);
}
function verifyBattleLifecycleContract(dataModule) {
dataModule.thirdCampPreparationPriorityIds.forEach((priorityId) => {
const memory = dataModule.resolveThirdCampPreparationMemory({
campaign: eligibleLiteralCampaign(dataModule, priorityId),
battleId: dataModule.thirdCampPreparationTargetBattleId
});
assert(memory);
const deployment =
dataModule.resolveThirdCampPreparationBattlePresentation({
memory,
stage: 'deployment'
});
const turnOne =
dataModule.resolveThirdCampPreparationBattlePresentation({
memory,
stage: 'turn',
turnNumber: 1
});
const turnThree =
dataModule.resolveThirdCampPreparationBattlePresentation({
memory,
stage: 'turn',
turnNumber: 3
});
const turnFour =
dataModule.resolveThirdCampPreparationBattlePresentation({
memory,
stage: 'turn',
turnNumber: 4
});
const applied =
dataModule.resolveThirdCampPreparationBattlePresentation({
memory,
stage: 'result',
usageCount: 2
});
const unused =
dataModule.resolveThirdCampPreparationBattlePresentation({
memory,
stage: 'result',
usageCount: 0
});
assert.deepEqual(
[
deployment?.status,
turnOne?.status,
turnThree?.status,
turnFour?.status,
applied?.status,
unused?.status
],
[
'deployment',
'active',
'active',
'expired',
'applied',
'unused'
]
);
[
deployment,
turnOne,
turnThree,
turnFour,
applied,
unused
].forEach((presentation) => {
assert.equal(
presentation?.selectionRecordId,
memory.selectionRecordId
);
assert.equal(presentation?.priorityId, memory.priorityId);
assert.equal(presentation?.label, memory.label);
assert.equal(
presentation?.activeThroughTurn,
dataModule.thirdCampPreparationActiveThroughTurn
);
});
assert.equal(
dataModule.resolveThirdCampPreparationBattlePresentation({
memory,
stage: 'turn',
turnNumber: 0
}),
undefined
);
const fresh = dataModule.resolveThirdCampPreparationMemory({
campaign: eligibleLiteralCampaign(dataModule, priorityId),
battleId: dataModule.thirdCampPreparationTargetBattleId
});
memory.label = 'mutated';
if ('unitIds' in memory.evidence) {
memory.evidence.unitIds[0] = 'mutated';
}
assert.notEqual(fresh?.label, 'mutated');
if (fresh?.evidence.kind === 'priority-return-dialogue') {
assert.notEqual(fresh.evidence.unitIds[0], 'mutated');
}
});
}
function verifyGuardedPersistentOverwrite(
dataModule,
actionModule,
campaignModule,
battleScenarios
) {
prepareThirdVictory(campaignModule, battleScenarios, dataModule);
assert.equal(
actionModule.setThirdCampPreparationPriority('forged').reason,
'invalid-priority'
);
assert.equal(
actionModule.setThirdCampPreparationPriority('information').reason,
'activity-required'
);
campaignModule.acknowledgeCampaignVictoryReward(
dataModule.thirdCampPreparationSourceBattleId,
['unlocks']
);
let result =
actionModule.setThirdCampPreparationPriority('information');
assert.equal(result.ok, true);
assert.equal(result.changed, true);
assert.equal(result.memory.priorityId, 'information');
assert.deepEqual(result.campaign.thirdCampPreparationSelection, {
sourceBattleId: dataModule.thirdCampPreparationSourceBattleId,
targetBattleId: dataModule.thirdCampPreparationTargetBattleId,
priorityId: 'information'
});
assert.equal(
result.campaign.completedCampVisits.filter(
(id) => id === dataModule.thirdCampPreparationSelectionRecordId
).length,
0,
'Mutable preparation must never masquerade as a completed camp visit.'
);
assert.equal(
actionModule.setThirdCampPreparationPriority('equipment').reason,
'activity-required'
);
assert.equal(
campaignModule.getCampaignState()
.thirdCampPreparationSelection?.priorityId,
'information',
'A rejected replacement must preserve the previous valid priority.'
);
campaignModule.acknowledgeCampaignVictoryReward(
dataModule.thirdCampPreparationSourceBattleId,
['equipment']
);
result = actionModule.setThirdCampPreparationPriority('equipment');
assert.equal(result.ok, true);
assert.equal(result.changed, true);
assert.equal(result.memory.priorityId, 'equipment');
completePriorityDialogue(
dataModule,
campaignModule,
battleScenarios
);
result = actionModule.setThirdCampPreparationPriority('companion');
assert.equal(result.ok, true);
assert.equal(result.changed, true);
assert.equal(result.memory.priorityId, 'companion');
assert.equal(result.memory.effect.bondId, 'guan-yu__zhang-fei');
const repeated =
actionModule.setThirdCampPreparationPriority('companion');
assert.equal(repeated.ok, true);
assert.equal(repeated.changed, false);
let reloaded = campaignModule.loadCampaignState();
assert.equal(
reloaded.thirdCampPreparationSelection?.priorityId,
'companion'
);
assert.equal(
dataModule.resolveThirdCampPreparationMemory({
campaign: reloaded,
battleId: dataModule.thirdCampPreparationTargetBattleId
})?.priorityId,
'companion'
);
const cleared = actionModule.clearThirdCampPreparationPriority();
assert.equal(cleared.ok, true);
assert.equal(cleared.changed, true);
assert.equal(
cleared.campaign.thirdCampPreparationSelection,
undefined
);
assert.equal(
cleared.campaign.completedCampVisits.includes(
dataModule.thirdCampPreparationSelectionRecordId
),
false
);
assert.equal(
cleared.campaign.firstBattleReport?.completedCampVisits.includes(
dataModule.thirdCampPreparationSelectionRecordId
),
false
);
result = actionModule.setThirdCampPreparationPriority('information');
assert.equal(result.ok, true);
const beforeFailedOverwrite = campaignModule.getCampaignState();
rejectStorageWrites = true;
let failedOverwrite;
try {
failedOverwrite =
actionModule.setThirdCampPreparationPriority('equipment');
} finally {
rejectStorageWrites = false;
}
assert.equal(failedOverwrite?.ok, false);
assert.equal(failedOverwrite?.reason, 'save-unavailable');
assert.equal(
campaignModule.getCampaignState()
.thirdCampPreparationSelection?.priorityId,
'information'
);
assert.equal(
JSON.stringify(
relevantSelectionState(
campaignModule.getCampaignState(),
dataModule
)
),
JSON.stringify(relevantSelectionState(beforeFailedOverwrite, dataModule))
);
const wrongStep = campaignModule.getCampaignState();
wrongStep.step = 'fourth-battle';
campaignModule.setCampaignState(wrongStep);
assert.equal(
actionModule.setThirdCampPreparationPriority('equipment').reason,
'invalid-campaign'
);
}
function verifyOldSaveAndPollutionSafety(
dataModule,
actionModule,
campaignModule,
battleScenarios
) {
prepareThirdVictory(campaignModule, battleScenarios, dataModule);
let state = campaignModule.getCampaignState();
delete state.thirdCampPreparationSelection;
state.campVisitChoiceIds = {
'existing-camp-visit': 'existing-choice'
};
state.completedCampVisits = ['existing-camp-visit'];
state.firstBattleReport.completedCampVisits = [
'existing-camp-visit'
];
state = campaignModule.setCampaignState(state);
assert.equal(state.thirdCampPreparationSelection, undefined);
assert.equal(
state.campVisitChoiceIds['existing-camp-visit'],
'existing-choice'
);
assert.deepEqual(state.completedCampVisits, [
'existing-camp-visit'
]);
assert.equal(
actionModule.thirdCampPreparationProgress(state)
.selectedPriorityId,
null
);
state.campVisitChoiceIds[
dataModule.thirdCampPreparationSelectionRecordId
] = 'information';
state.completedCampVisits.push(
dataModule.thirdCampPreparationSelectionRecordId
);
state.firstBattleReport.completedCampVisits.push(
dataModule.thirdCampPreparationSelectionRecordId
);
state = campaignModule.setCampaignState(state);
assert.equal(
state.campVisitChoiceIds[
dataModule.thirdCampPreparationSelectionRecordId
],
undefined,
'The abandoned camp-choice storage key must be scrubbed.'
);
assert.equal(state.thirdCampPreparationSelection, undefined);
assert.equal(
state.completedCampVisits.includes(
dataModule.thirdCampPreparationSelectionRecordId
),
false
);
assert.equal(
state.firstBattleReport.completedCampVisits.includes(
dataModule.thirdCampPreparationSelectionRecordId
),
false
);
assert.equal(
state.campVisitChoiceIds['existing-camp-visit'],
'existing-choice',
'Legacy cleanup must not damage real camp history.'
);
const corruptSelections = [
null,
'information',
[],
{},
{
sourceBattleId: 'fourth-battle-guangzong-camp',
targetBattleId: dataModule.thirdCampPreparationTargetBattleId,
priorityId: 'information'
},
{
sourceBattleId: dataModule.thirdCampPreparationSourceBattleId,
targetBattleId: 'fifth-battle-sishui-vanguard',
priorityId: 'information'
},
{
sourceBattleId: dataModule.thirdCampPreparationSourceBattleId,
targetBattleId: dataModule.thirdCampPreparationTargetBattleId,
priorityId: 'forged'
}
];
corruptSelections.forEach((selection) => {
const candidate = campaignModule.getCampaignState();
candidate.thirdCampPreparationSelection = selection;
const normalized = campaignModule.setCampaignState(candidate);
assert.equal(
normalized.thirdCampPreparationSelection,
undefined
);
});
state = campaignModule.getCampaignState();
state.thirdCampPreparationSelection = {
sourceBattleId: dataModule.thirdCampPreparationSourceBattleId,
targetBattleId: dataModule.thirdCampPreparationTargetBattleId,
priorityId: 'information'
};
state = campaignModule.setCampaignState(state);
assert.equal(
state.thirdCampPreparationSelection,
undefined,
'A structurally valid but activity-locked priority must be removed.'
);
campaignModule.acknowledgeCampaignVictoryReward(
dataModule.thirdCampPreparationSourceBattleId,
['unlocks']
);
const validCandidate = campaignModule.getCampaignState();
validCandidate.thirdCampPreparationSelection = {
sourceBattleId: dataModule.thirdCampPreparationSourceBattleId,
targetBattleId: dataModule.thirdCampPreparationTargetBattleId,
priorityId: 'information',
polluted: true
};
state = campaignModule.setCampaignState(validCandidate);
assert.deepEqual(state.thirdCampPreparationSelection, {
sourceBattleId: dataModule.thirdCampPreparationSourceBattleId,
targetBattleId: dataModule.thirdCampPreparationTargetBattleId,
priorityId: 'information'
});
validCandidate.thirdCampPreparationSelection.priorityId =
'equipment';
assert.equal(
campaignModule.getCampaignState()
.thirdCampPreparationSelection?.priorityId,
'information',
'The normalized selection must be deep-cloned from caller state.'
);
state = campaignModule.getCampaignState();
state.thirdCampPreparationSelection = {
sourceBattleId: dataModule.thirdCampPreparationSourceBattleId,
targetBattleId: dataModule.thirdCampPreparationTargetBattleId,
priorityId: 'equipment'
};
state = campaignModule.setCampaignState(state);
assert.equal(
state.thirdCampPreparationSelection,
undefined,
'A different locked priority must not borrow another activity proof.'
);
assert.equal(
dataModule.resolveThirdCampPreparationSelection(state),
undefined
);
assert.equal(
dataModule.resolveThirdCampPreparationMemory({
campaign: state,
battleId: dataModule.thirdCampPreparationTargetBattleId
}),
undefined
);
const legacy = literalCampaign(dataModule, {
acknowledgedCategories: ['unlocks'],
selectedPriorityId: 'information',
legacyReportOnly: true
});
const legacyMemory =
dataModule.resolveThirdCampPreparationMemory({
campaign: legacy,
battleId: dataModule.thirdCampPreparationTargetBattleId
});
assert.equal(legacyMemory?.priorityId, 'information');
assert.equal(
legacyMemory?.evidence.kind,
'victory-reward-category'
);
const legacyCompanion = literalCampaign(dataModule, {
completePriorityDialogue: true,
selectedPriorityId: 'companion',
legacyReportOnly: true
});
assert.equal(
dataModule.resolveThirdCampPreparationMemory({
campaign: legacyCompanion,
battleId: dataModule.thirdCampPreparationTargetBattleId
})?.priorityId,
'companion'
);
const legacyWrongReport = structuredClone(legacy);
legacyWrongReport.firstBattleReport.battleId =
'fourth-battle-guangzong-camp';
assert.equal(
dataModule.resolveThirdCampPreparationMemory({
campaign: legacyWrongReport,
battleId: dataModule.thirdCampPreparationTargetBattleId
}),
undefined
);
}
function prepareThirdVictory(
campaignModule,
battleScenarios,
dataModule
) {
storage.clear();
campaignModule.resetCampaignState();
const scenario =
battleScenarios[dataModule.thirdCampPreparationSourceBattleId];
campaignModule.setFirstBattleReport({
battleId: scenario.id,
battleTitle: scenario.title,
outcome: 'victory',
turnNumber: 22,
rewardGold: scenario.baseVictoryGold,
defeatedEnemies: scenario.units.filter(
(unit) => unit.faction === 'enemy'
).length,
totalEnemies: scenario.units.filter(
(unit) => unit.faction === 'enemy'
).length,
objectives: scenario.objectives.map((objective) => ({
id: objective.id,
label: objective.label,
achieved: true,
status: 'done',
detail: '달성',
rewardGold: objective.rewardGold
})),
units: scenario.units,
bonds: scenario.bonds.map((bond) => ({
...bond,
battleExp: 0
})),
itemRewards: [...scenario.itemRewards],
campaignRewards: {
supplies: [...(scenario.campaignReward?.supplies ?? [])],
equipment: [...(scenario.campaignReward?.equipment ?? [])],
reputation: [...(scenario.campaignReward?.reputation ?? [])],
recruits: [],
unlocks: scenario.campaignReward?.unlockBattleId
? [{
battleId: scenario.campaignReward.unlockBattleId,
title:
scenario.campaignReward.unlockLabel ??
scenario.campaignReward.unlockBattleId
}]
: [],
note: scenario.campaignReward?.note
},
completedCampDialogues: [],
completedCampVisits: [],
createdAt: '2026-07-27T00:00:00.000Z'
});
const campaign = campaignModule.getCampaignState();
assert.equal(campaign.step, 'third-camp');
assert.equal(
campaign.latestBattleId,
dataModule.thirdCampPreparationSourceBattleId
);
return campaign;
}
function completePriorityDialogue(
dataModule,
campaignModule,
battleScenarios
) {
const campaign = campaignModule.getCampaignState();
const dialogueModuleResult = priorityDialogueForCampaign(
campaign,
dataModule
);
const bondByDialogueId = {
'liu-guan-after-guangzong-road': 'liu-bei__guan-yu',
'liu-zhang-after-guangzong-road': 'liu-bei__zhang-fei',
'guan-zhang-after-guangzong-road': 'guan-yu__zhang-fei'
};
const bondId = bondByDialogueId[dialogueModuleResult.targetDialogueId];
assert(
battleScenarios[
dataModule.thirdCampPreparationSourceBattleId
].bonds.some((bond) => bond.id === bondId)
);
const report = campaignModule.applyCampBondExp(
dialogueModuleResult.targetDialogueId,
bondId,
1,
'review-third-battle-result'
);
assert(
report?.completedCampDialogues.includes(
dialogueModuleResult.targetDialogueId
)
);
}
function priorityDialogueForCampaign(campaign, dataModule) {
const record =
campaign.battleHistory[
dataModule.thirdCampPreparationSourceBattleId
] ?? campaign.firstBattleReport;
const fort = record.objectives.find(({ id }) => id === 'fort');
const quick = record.objectives.find(({ id }) => id === 'quick');
if (!fort.achieved) {
return {
targetDialogueId: 'liu-guan-after-guangzong-road'
};
}
if (!quick.achieved) {
return {
targetDialogueId: 'liu-zhang-after-guangzong-road'
};
}
return {
targetDialogueId: 'guan-zhang-after-guangzong-road'
};
}
function eligibleLiteralCampaign(dataModule, priorityId) {
return literalCampaign(dataModule, {
acknowledgedCategories:
priorityId === 'information'
? ['unlocks']
: priorityId === 'equipment'
? ['equipment']
: [],
completePriorityDialogue: priorityId === 'companion',
selectedPriorityId: priorityId
});
}
function literalCampaign(
dataModule,
{
acknowledgedCategories = [],
completePriorityDialogue = false,
selectedPriorityId,
legacyReportOnly = false
} = {}
) {
const report = {
battleId: dataModule.thirdCampPreparationSourceBattleId,
outcome: 'victory',
turnNumber: 22,
objectives: [
{ id: 'fort', achieved: true, status: 'done' },
{ id: 'quick', achieved: true, status: 'done' }
],
units: []
};
return {
step: 'third-camp',
latestBattleId: dataModule.thirdCampPreparationSourceBattleId,
battleHistory: legacyReportOnly
? {}
: {
[dataModule.thirdCampPreparationSourceBattleId]:
structuredClone(report)
},
firstBattleReport: structuredClone(report),
completedCampDialogues: completePriorityDialogue
? ['guan-zhang-after-guangzong-road']
: [],
completedCampVisits: [],
campVisitChoiceIds: {},
...(selectedPriorityId
? {
thirdCampPreparationSelection: {
sourceBattleId:
dataModule.thirdCampPreparationSourceBattleId,
targetBattleId:
dataModule.thirdCampPreparationTargetBattleId,
priorityId: selectedPriorityId
}
}
: {}),
acknowledgedVictoryRewardCategories:
acknowledgedCategories.length > 0
? {
[dataModule.thirdCampPreparationSourceBattleId]:
[...acknowledgedCategories]
}
: {}
};
}
function relevantSelectionState(campaign, dataModule) {
return {
selection: campaign.thirdCampPreparationSelection ?? null,
completed: campaign.completedCampVisits.filter(
(id) => id === dataModule.thirdCampPreparationSelectionRecordId
),
reportCompleted:
campaign.firstBattleReport?.completedCampVisits.filter(
(id) => id === dataModule.thirdCampPreparationSelectionRecordId
) ?? []
};
}

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.
}
}