feat: carry third-camp preparation into battle
This commit is contained in:
@@ -187,9 +187,28 @@ RPG로 다듬는다.
|
||||
다시 군영에 들어와도 완료 상태를 유지한다.
|
||||
- 네 가지 목표 조합과 Canvas·WebGL의 1920x1080 진행을 자동 검증한다.
|
||||
|
||||
다음 초반 묶음은 세 번째 군영에서 정보 수집, 장비 정비, 동료 대화 가운데
|
||||
이번 출진의 준비 우선순위 하나를 직접 정하고, 그 선택이 네 번째 전투
|
||||
브리핑과 첫 3턴 안에 짧고 명확하게 되돌아오게 한다.
|
||||
### 완료한 9차 묶음: 세 번째 군영의 준비 우선순위와 네 번째 전투 회수
|
||||
|
||||
- 세 번째 승리 뒤 군영에서 `본영 정보`, `장비 정비`, `동료 공명` 가운데
|
||||
이번 출진의 준비 우선순위 하나를 직접 정한다.
|
||||
- 승전 보고의 정보·장비 범주를 실제로 확인했거나 결과별 우선 귀환 대화를
|
||||
완료한 항목만 선택할 수 있어, 보지 않은 활동을 완료한 것처럼 취급하지
|
||||
않는다.
|
||||
- 본영 정보는 배치 때 표식 선봉의 첫 행동을 보여 주고 첫 3턴 안 해당
|
||||
대상 첫 공격의 명중을 한 번 `+10%p`, 장비 정비는 같은 기간 아군의 첫
|
||||
피격 피해를 한 번 `-8%` 보정한다.
|
||||
- 동료 공명은 우선 귀환 대화에 참여한 정확한 공명조에만 첫 3턴 협공
|
||||
확률 `+5%p`를 적용하며, 다른 조합에는 옮겨 붙지 않는다.
|
||||
- 선택부터 네 번째 전투의 배치·턴·결과까지 같은 `selectionRecordId`와
|
||||
항목명을 사용하고, 결과에서 실제 발동 여부와 횟수를 되돌려 보여 준다.
|
||||
- 전투 저장에는 발동 소비 여부, 공명 시도·성공 횟수와 막은 피해를 함께
|
||||
보존해 재개해도 효과가 되살아나거나 중복 적용되지 않게 한다.
|
||||
- 전투 장면을 연속으로 재사용해도 이전 전투의 턴과 진영이 남지 않도록
|
||||
매 전투를 `1턴 · 아군`에서 초기화한다.
|
||||
|
||||
다음 묶음은 서주 체류에서 장비를 구매한 뒤 실제 무장에게 장착하고, 그
|
||||
선택을 여덟 번째 전투의 출진 전 비교·전투 수치·결과 피드백까지 같은
|
||||
장비 기록으로 이어지게 한다.
|
||||
|
||||
## 장기 개선 순서
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
1656
scripts/verify-third-camp-preparation-browser.mjs
Normal file
1656
scripts/verify-third-camp-preparation-browser.mjs
Normal file
File diff suppressed because it is too large
Load Diff
963
scripts/verify-third-camp-preparation.mjs
Normal file
963
scripts/verify-third-camp-preparation.mjs
Normal 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
|
||||
) ?? []
|
||||
};
|
||||
}
|
||||
639
src/game/data/thirdCampPreparation.ts
Normal file
639
src/game/data/thirdCampPreparation.ts
Normal file
@@ -0,0 +1,639 @@
|
||||
import {
|
||||
resolveThirdBattlePriorityReturnDialogue,
|
||||
thirdBattleReturnSourceBattleId
|
||||
} from './thirdBattleReturnDialogue';
|
||||
|
||||
export const thirdCampPreparationSourceBattleId =
|
||||
thirdBattleReturnSourceBattleId;
|
||||
export const thirdCampPreparationTargetBattleId =
|
||||
'fourth-battle-guangzong-camp';
|
||||
export const thirdCampPreparationSelectionRecordId =
|
||||
'third-camp-preparation-priority';
|
||||
export const thirdCampPreparationActiveThroughTurn = 3;
|
||||
export const thirdCampPreparationInformationEnemyUnitId =
|
||||
'guangzong-main-vanguard-a';
|
||||
export const thirdCampPreparationPriorityIds = [
|
||||
'information',
|
||||
'equipment',
|
||||
'companion'
|
||||
] as const;
|
||||
|
||||
export type ThirdCampPreparationPriorityId =
|
||||
(typeof thirdCampPreparationPriorityIds)[number];
|
||||
|
||||
export type ThirdCampPreparationInformationEffect = {
|
||||
kind: 'marked-vanguard-intel';
|
||||
activeThroughTurn: typeof thirdCampPreparationActiveThroughTurn;
|
||||
deploymentPreviewCount: 1;
|
||||
trackedEnemyUnitId:
|
||||
typeof thirdCampPreparationInformationEnemyUnitId;
|
||||
accuracyBonus: 10;
|
||||
maxBattleTriggers: 1;
|
||||
};
|
||||
|
||||
export type ThirdCampPreparationEquipmentEffect = {
|
||||
kind: 'prepared-equipment-guard';
|
||||
activeThroughTurn: typeof thirdCampPreparationActiveThroughTurn;
|
||||
incomingDamageReductionPercent: 8;
|
||||
minimumDamage: 1;
|
||||
maxBattleTriggers: 1;
|
||||
targetRule: 'first-allied-hit';
|
||||
};
|
||||
|
||||
export type ThirdCampPreparationCompanionEffect = {
|
||||
kind: 'return-bond-opening';
|
||||
activeThroughTurn: typeof thirdCampPreparationActiveThroughTurn;
|
||||
cooperationRateBonus: 5;
|
||||
};
|
||||
|
||||
export type ThirdCampPreparationEffect =
|
||||
| ThirdCampPreparationInformationEffect
|
||||
| ThirdCampPreparationEquipmentEffect
|
||||
| ThirdCampPreparationCompanionEffect;
|
||||
|
||||
export type ThirdCampPreparationPriorityDefinition = {
|
||||
id: ThirdCampPreparationPriorityId;
|
||||
label: string;
|
||||
activityLabel: string;
|
||||
summary: string;
|
||||
deploymentLine: string;
|
||||
activeLine: string;
|
||||
appliedLine: string;
|
||||
unusedLine: string;
|
||||
effect: ThirdCampPreparationEffect;
|
||||
};
|
||||
|
||||
export const thirdCampPreparationPriorities: readonly
|
||||
ThirdCampPreparationPriorityDefinition[] = [
|
||||
{
|
||||
id: 'information',
|
||||
label: '본영 정보',
|
||||
activityLabel: '승전 보고 확인',
|
||||
summary:
|
||||
'광종 본영 선봉 한 명의 첫 행동을 배치 때 확인하고, 3턴 안 첫 공격 명중을 10%p 높입니다.',
|
||||
deploymentLine:
|
||||
'본영 정보 · 선봉 적 1명의 의도와 표식을 배치 단계에서 먼저 확인',
|
||||
activeLine:
|
||||
'본영 정보 · 첫 3턴 안 표식 대상 첫 공격 명중 +10%p',
|
||||
appliedLine:
|
||||
'본영 정보 · 표식 대상 첫 공격 명중 보정 적용',
|
||||
unusedLine:
|
||||
'본영 정보 · 표식 대상을 공격하기 전에 준비 시간이 끝남',
|
||||
effect: {
|
||||
kind: 'marked-vanguard-intel',
|
||||
activeThroughTurn: thirdCampPreparationActiveThroughTurn,
|
||||
deploymentPreviewCount: 1,
|
||||
trackedEnemyUnitId:
|
||||
thirdCampPreparationInformationEnemyUnitId,
|
||||
accuracyBonus: 10,
|
||||
maxBattleTriggers: 1
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'equipment',
|
||||
label: '장비 정비',
|
||||
activityLabel: '승전 장비 확인',
|
||||
summary:
|
||||
'3차 승전 장비를 확인하면 첫 3턴 아군의 첫 피격 피해를 한 번 8% 줄입니다.',
|
||||
deploymentLine:
|
||||
'장비 정비 · 아군 첫 피격 1회 방호 준비',
|
||||
activeLine:
|
||||
'장비 정비 · 첫 3턴 동안 아군 첫 피격 1회 피해 -8%',
|
||||
appliedLine:
|
||||
'장비 정비 · 아군 첫 피격 방호 적용',
|
||||
unusedLine:
|
||||
'장비 정비 · 방호가 발동하기 전에 준비 시간이 끝남',
|
||||
effect: {
|
||||
kind: 'prepared-equipment-guard',
|
||||
activeThroughTurn: thirdCampPreparationActiveThroughTurn,
|
||||
incomingDamageReductionPercent: 8,
|
||||
minimumDamage: 1,
|
||||
maxBattleTriggers: 1,
|
||||
targetRule: 'first-allied-hit'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'companion',
|
||||
label: '동료 공명',
|
||||
activityLabel: '우선 귀환 대화',
|
||||
summary:
|
||||
'우선 귀환 대화를 나눈 공명조는 첫 3턴 협공 확률이 5%p 오릅니다.',
|
||||
deploymentLine:
|
||||
'동료 공명 · 귀환 대화 공명조의 초반 협공 신호 준비',
|
||||
activeLine:
|
||||
'동료 공명 · 첫 3턴 동안 해당 공명조 협공 확률 +5%p',
|
||||
appliedLine:
|
||||
'동료 공명 · 귀환 대화에서 맞춘 협공 신호 발동',
|
||||
unusedLine:
|
||||
'동료 공명 · 해당 공명조가 초반 협공을 잇지 못함',
|
||||
effect: {
|
||||
kind: 'return-bond-opening',
|
||||
activeThroughTurn: thirdCampPreparationActiveThroughTurn,
|
||||
cooperationRateBonus: 5
|
||||
}
|
||||
}
|
||||
] as const;
|
||||
|
||||
export type ThirdCampPreparationEvidence =
|
||||
| {
|
||||
kind: 'victory-reward-category';
|
||||
battleId: typeof thirdCampPreparationSourceBattleId;
|
||||
category: 'unlocks' | 'equipment';
|
||||
}
|
||||
| {
|
||||
kind: 'priority-return-dialogue';
|
||||
battleId: typeof thirdCampPreparationSourceBattleId;
|
||||
dialogueId: string;
|
||||
bondId: string;
|
||||
unitIds: [string, string];
|
||||
};
|
||||
|
||||
export type ThirdCampPreparationUnavailableReason =
|
||||
| 'battle-record-required'
|
||||
| 'report-review-required'
|
||||
| 'equipment-change-required'
|
||||
| 'priority-dialogue-required';
|
||||
|
||||
export type ThirdCampPreparationAvailability = {
|
||||
priority: ThirdCampPreparationPriorityDefinition;
|
||||
selectable: boolean;
|
||||
evidence?: ThirdCampPreparationEvidence;
|
||||
unavailableReason?: ThirdCampPreparationUnavailableReason;
|
||||
};
|
||||
|
||||
export type ResolvedThirdCampPreparationEffect =
|
||||
| ThirdCampPreparationInformationEffect
|
||||
| ThirdCampPreparationEquipmentEffect
|
||||
| (ThirdCampPreparationCompanionEffect & {
|
||||
bondId: string;
|
||||
unitIds: [string, string];
|
||||
});
|
||||
|
||||
export type ThirdCampPreparationMemory = {
|
||||
sourceBattleId: typeof thirdCampPreparationSourceBattleId;
|
||||
targetBattleId: typeof thirdCampPreparationTargetBattleId;
|
||||
selectionRecordId: typeof thirdCampPreparationSelectionRecordId;
|
||||
priorityId: ThirdCampPreparationPriorityId;
|
||||
label: string;
|
||||
summary: string;
|
||||
deploymentLine: string;
|
||||
activeLine: string;
|
||||
appliedLine: string;
|
||||
unusedLine: string;
|
||||
evidence: ThirdCampPreparationEvidence;
|
||||
effect: ResolvedThirdCampPreparationEffect;
|
||||
};
|
||||
|
||||
export type ThirdCampPreparationBattlePresentation = {
|
||||
selectionRecordId: typeof thirdCampPreparationSelectionRecordId;
|
||||
priorityId: ThirdCampPreparationPriorityId;
|
||||
label: string;
|
||||
status:
|
||||
| 'deployment'
|
||||
| 'active'
|
||||
| 'expired'
|
||||
| 'applied'
|
||||
| 'unused';
|
||||
text: string;
|
||||
activeThroughTurn: typeof thirdCampPreparationActiveThroughTurn;
|
||||
turnNumber: number | null;
|
||||
usageCount: number;
|
||||
};
|
||||
|
||||
export type ThirdCampPreparationCampaignState = {
|
||||
step?: unknown;
|
||||
latestBattleId?: unknown;
|
||||
battleHistory?: Readonly<
|
||||
Record<string, ThirdCampPreparationBattleRecord | null | undefined>
|
||||
>;
|
||||
firstBattleReport?: ThirdCampPreparationBattleRecord | null;
|
||||
completedCampDialogues?: readonly unknown[];
|
||||
thirdCampPreparationSelection?: {
|
||||
sourceBattleId?: unknown;
|
||||
targetBattleId?: unknown;
|
||||
priorityId?: unknown;
|
||||
} | null;
|
||||
acknowledgedVictoryRewardCategories?: Readonly<
|
||||
Record<string, readonly unknown[] | undefined>
|
||||
>;
|
||||
};
|
||||
|
||||
type ThirdCampPreparationBattleRecord = {
|
||||
battleId?: unknown;
|
||||
outcome?: unknown;
|
||||
turnNumber?: unknown;
|
||||
objectives?: unknown;
|
||||
units?: unknown;
|
||||
};
|
||||
|
||||
type ThirdBattleRecordSelection = {
|
||||
record: ThirdCampPreparationBattleRecord;
|
||||
source: 'battle-history' | 'legacy-report';
|
||||
};
|
||||
|
||||
const companionBondByDialogueId: Readonly<
|
||||
Record<string, { bondId: string; unitIds: [string, string] }>
|
||||
> = {
|
||||
'liu-guan-after-guangzong-road': {
|
||||
bondId: 'liu-bei__guan-yu',
|
||||
unitIds: ['liu-bei', 'guan-yu']
|
||||
},
|
||||
'liu-zhang-after-guangzong-road': {
|
||||
bondId: 'liu-bei__zhang-fei',
|
||||
unitIds: ['liu-bei', 'zhang-fei']
|
||||
},
|
||||
'guan-zhang-after-guangzong-road': {
|
||||
bondId: 'guan-yu__zhang-fei',
|
||||
unitIds: ['guan-yu', 'zhang-fei']
|
||||
}
|
||||
};
|
||||
|
||||
export function isThirdCampPreparationPriorityId(
|
||||
value: unknown
|
||||
): value is ThirdCampPreparationPriorityId {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
(thirdCampPreparationPriorityIds as readonly string[]).includes(
|
||||
value
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function getThirdCampPreparationPriority(
|
||||
priorityId: unknown
|
||||
): ThirdCampPreparationPriorityDefinition | undefined {
|
||||
const definition = isThirdCampPreparationPriorityId(priorityId)
|
||||
? thirdCampPreparationPriorities.find(
|
||||
(candidate) => candidate.id === priorityId
|
||||
)
|
||||
: undefined;
|
||||
return definition ? clonePriority(definition) : undefined;
|
||||
}
|
||||
|
||||
export function hasThirdCampPreparationSourceVictory(
|
||||
campaign?: ThirdCampPreparationCampaignState | null
|
||||
) {
|
||||
return Boolean(selectThirdBattleVictoryRecord(campaign));
|
||||
}
|
||||
|
||||
export function resolveThirdCampPreparationAvailability(
|
||||
campaign?: ThirdCampPreparationCampaignState | null
|
||||
): ThirdCampPreparationAvailability[] {
|
||||
const source = selectThirdBattleVictoryRecord(campaign);
|
||||
if (!source) {
|
||||
return thirdCampPreparationPriorities.map((priority) => ({
|
||||
priority: clonePriority(priority),
|
||||
selectable: false,
|
||||
unavailableReason: 'battle-record-required'
|
||||
}));
|
||||
}
|
||||
|
||||
const informationEvidence = acknowledgedRewardEvidence(
|
||||
campaign,
|
||||
'unlocks'
|
||||
);
|
||||
const equipmentEvidence = acknowledgedRewardEvidence(
|
||||
campaign,
|
||||
'equipment'
|
||||
);
|
||||
const companionEvidence = completedCompanionEvidence(campaign);
|
||||
const evidenceByPriority: Partial<
|
||||
Record<ThirdCampPreparationPriorityId, ThirdCampPreparationEvidence>
|
||||
> = {
|
||||
...(informationEvidence
|
||||
? { information: informationEvidence }
|
||||
: {}),
|
||||
...(equipmentEvidence ? { equipment: equipmentEvidence } : {}),
|
||||
...(companionEvidence ? { companion: companionEvidence } : {})
|
||||
};
|
||||
const missingReasonByPriority: Record<
|
||||
ThirdCampPreparationPriorityId,
|
||||
ThirdCampPreparationUnavailableReason
|
||||
> = {
|
||||
information: 'report-review-required',
|
||||
equipment: 'equipment-change-required',
|
||||
companion: 'priority-dialogue-required'
|
||||
};
|
||||
|
||||
return thirdCampPreparationPriorities.map((priority) => {
|
||||
const evidence = evidenceByPriority[priority.id];
|
||||
return {
|
||||
priority: clonePriority(priority),
|
||||
selectable: Boolean(evidence),
|
||||
...(evidence
|
||||
? { evidence: cloneEvidence(evidence) }
|
||||
: {
|
||||
unavailableReason: missingReasonByPriority[priority.id]
|
||||
})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveThirdCampPreparationSelection(
|
||||
campaign?: ThirdCampPreparationCampaignState | null
|
||||
) {
|
||||
const selection = campaign?.thirdCampPreparationSelection;
|
||||
if (
|
||||
selection?.sourceBattleId !==
|
||||
thirdCampPreparationSourceBattleId ||
|
||||
selection.targetBattleId !==
|
||||
thirdCampPreparationTargetBattleId
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const priorityId = selection.priorityId;
|
||||
if (!isThirdCampPreparationPriorityId(priorityId)) {
|
||||
return undefined;
|
||||
}
|
||||
const availability = resolveThirdCampPreparationAvailability(
|
||||
campaign
|
||||
).find((entry) => entry.priority.id === priorityId);
|
||||
return availability
|
||||
? {
|
||||
priorityId,
|
||||
label: availability.priority.label,
|
||||
selectable: availability.selectable,
|
||||
...(availability.evidence
|
||||
? { evidence: cloneEvidence(availability.evidence) }
|
||||
: {}),
|
||||
...(availability.unavailableReason
|
||||
? { unavailableReason: availability.unavailableReason }
|
||||
: {})
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function resolveThirdCampPreparationMemory(options: {
|
||||
campaign?: ThirdCampPreparationCampaignState | null;
|
||||
battleId?: string;
|
||||
}): ThirdCampPreparationMemory | undefined {
|
||||
if (options.battleId !== thirdCampPreparationTargetBattleId) {
|
||||
return undefined;
|
||||
}
|
||||
const selection = resolveThirdCampPreparationSelection(
|
||||
options.campaign
|
||||
);
|
||||
if (!selection?.selectable || !selection.evidence) {
|
||||
return undefined;
|
||||
}
|
||||
const priority = getThirdCampPreparationPriority(
|
||||
selection.priorityId
|
||||
);
|
||||
if (!priority) {
|
||||
return undefined;
|
||||
}
|
||||
const effect = resolveEffect(priority.effect, selection.evidence);
|
||||
if (!effect) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
sourceBattleId: thirdCampPreparationSourceBattleId,
|
||||
targetBattleId: thirdCampPreparationTargetBattleId,
|
||||
selectionRecordId: thirdCampPreparationSelectionRecordId,
|
||||
priorityId: priority.id,
|
||||
label: priority.label,
|
||||
summary: priority.summary,
|
||||
deploymentLine: priority.deploymentLine,
|
||||
activeLine: priority.activeLine,
|
||||
appliedLine: priority.appliedLine,
|
||||
unusedLine: priority.unusedLine,
|
||||
evidence: cloneEvidence(selection.evidence),
|
||||
effect
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveThirdCampPreparationBattlePresentation(options: {
|
||||
memory: ThirdCampPreparationMemory;
|
||||
stage: 'deployment' | 'turn' | 'result';
|
||||
turnNumber?: number;
|
||||
usageCount?: number;
|
||||
}): ThirdCampPreparationBattlePresentation | undefined {
|
||||
const priority = getThirdCampPreparationPriority(
|
||||
options.memory.priorityId
|
||||
);
|
||||
if (
|
||||
!priority ||
|
||||
priority.label !== options.memory.label ||
|
||||
options.memory.targetBattleId !==
|
||||
thirdCampPreparationTargetBattleId
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const usageCount = nonNegativeInteger(options.usageCount);
|
||||
if (options.stage === 'deployment') {
|
||||
return presentationBase(
|
||||
options.memory,
|
||||
'deployment',
|
||||
options.memory.deploymentLine,
|
||||
null,
|
||||
usageCount
|
||||
);
|
||||
}
|
||||
if (options.stage === 'turn') {
|
||||
const turnNumber = positiveInteger(options.turnNumber);
|
||||
if (!turnNumber) {
|
||||
return undefined;
|
||||
}
|
||||
const active =
|
||||
turnNumber <= thirdCampPreparationActiveThroughTurn;
|
||||
return presentationBase(
|
||||
options.memory,
|
||||
active ? 'active' : 'expired',
|
||||
active
|
||||
? `${options.memory.activeLine} · ${turnNumber}/${thirdCampPreparationActiveThroughTurn}턴`
|
||||
: `${options.memory.label} · 준비 효과 종료`,
|
||||
turnNumber,
|
||||
usageCount
|
||||
);
|
||||
}
|
||||
|
||||
const applied = usageCount > 0;
|
||||
return presentationBase(
|
||||
options.memory,
|
||||
applied ? 'applied' : 'unused',
|
||||
applied
|
||||
? `${options.memory.appliedLine} · ${usageCount}회`
|
||||
: options.memory.unusedLine,
|
||||
null,
|
||||
usageCount
|
||||
);
|
||||
}
|
||||
|
||||
function selectThirdBattleVictoryRecord(
|
||||
campaign?: ThirdCampPreparationCampaignState | null
|
||||
): ThirdBattleRecordSelection | undefined {
|
||||
const history = campaign?.battleHistory;
|
||||
if (
|
||||
history &&
|
||||
Object.prototype.hasOwnProperty.call(
|
||||
history,
|
||||
thirdCampPreparationSourceBattleId
|
||||
)
|
||||
) {
|
||||
const record = history[thirdCampPreparationSourceBattleId];
|
||||
return validThirdBattleVictory(record)
|
||||
? { record, source: 'battle-history' }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const record = campaign?.firstBattleReport;
|
||||
return validThirdBattleVictory(record)
|
||||
? { record, source: 'legacy-report' }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function validThirdBattleVictory(
|
||||
record?: ThirdCampPreparationBattleRecord | null
|
||||
): record is ThirdCampPreparationBattleRecord {
|
||||
return Boolean(
|
||||
record &&
|
||||
record.battleId === thirdCampPreparationSourceBattleId &&
|
||||
record.outcome === 'victory'
|
||||
);
|
||||
}
|
||||
|
||||
function acknowledgedRewardEvidence(
|
||||
campaign: ThirdCampPreparationCampaignState | null | undefined,
|
||||
category: 'unlocks' | 'equipment'
|
||||
): Extract<
|
||||
ThirdCampPreparationEvidence,
|
||||
{ kind: 'victory-reward-category' }
|
||||
> | undefined {
|
||||
const acknowledged =
|
||||
campaign?.acknowledgedVictoryRewardCategories?.[
|
||||
thirdCampPreparationSourceBattleId
|
||||
];
|
||||
return Array.isArray(acknowledged) &&
|
||||
acknowledged.includes(category)
|
||||
? {
|
||||
kind: 'victory-reward-category',
|
||||
battleId: thirdCampPreparationSourceBattleId,
|
||||
category
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function completedCompanionEvidence(
|
||||
campaign?: ThirdCampPreparationCampaignState | null
|
||||
): Extract<
|
||||
ThirdCampPreparationEvidence,
|
||||
{ kind: 'priority-return-dialogue' }
|
||||
> | undefined {
|
||||
const dialogue = resolveThirdBattlePriorityReturnDialogue(campaign);
|
||||
if (
|
||||
!dialogue ||
|
||||
!arrayValue(campaign?.completedCampDialogues).includes(
|
||||
dialogue.targetDialogueId
|
||||
)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const bond = companionBondByDialogueId[dialogue.targetDialogueId];
|
||||
return bond
|
||||
? {
|
||||
kind: 'priority-return-dialogue',
|
||||
battleId: thirdCampPreparationSourceBattleId,
|
||||
dialogueId: dialogue.targetDialogueId,
|
||||
bondId: bond.bondId,
|
||||
unitIds: [...bond.unitIds]
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolveEffect(
|
||||
effect: ThirdCampPreparationEffect,
|
||||
evidence: ThirdCampPreparationEvidence
|
||||
): ResolvedThirdCampPreparationEffect | undefined {
|
||||
if (
|
||||
effect.kind === 'marked-vanguard-intel' &&
|
||||
evidence.kind === 'victory-reward-category' &&
|
||||
evidence.category === 'unlocks'
|
||||
) {
|
||||
return { ...effect };
|
||||
}
|
||||
if (
|
||||
effect.kind === 'prepared-equipment-guard' &&
|
||||
evidence.kind === 'victory-reward-category' &&
|
||||
evidence.category === 'equipment'
|
||||
) {
|
||||
return { ...effect };
|
||||
}
|
||||
if (
|
||||
effect.kind === 'return-bond-opening' &&
|
||||
evidence.kind === 'priority-return-dialogue'
|
||||
) {
|
||||
return {
|
||||
...effect,
|
||||
bondId: evidence.bondId,
|
||||
unitIds: [...evidence.unitIds]
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function presentationBase(
|
||||
memory: ThirdCampPreparationMemory,
|
||||
status: ThirdCampPreparationBattlePresentation['status'],
|
||||
text: string,
|
||||
turnNumber: number | null,
|
||||
usageCount: number
|
||||
): ThirdCampPreparationBattlePresentation {
|
||||
return {
|
||||
selectionRecordId: thirdCampPreparationSelectionRecordId,
|
||||
priorityId: memory.priorityId,
|
||||
label: memory.label,
|
||||
status,
|
||||
text,
|
||||
activeThroughTurn: thirdCampPreparationActiveThroughTurn,
|
||||
turnNumber,
|
||||
usageCount
|
||||
};
|
||||
}
|
||||
|
||||
function clonePriority(
|
||||
priority: ThirdCampPreparationPriorityDefinition
|
||||
): ThirdCampPreparationPriorityDefinition {
|
||||
return {
|
||||
...priority,
|
||||
effect: { ...priority.effect }
|
||||
} as ThirdCampPreparationPriorityDefinition;
|
||||
}
|
||||
|
||||
function cloneEvidence(
|
||||
evidence: ThirdCampPreparationEvidence
|
||||
): ThirdCampPreparationEvidence {
|
||||
if (evidence.kind === 'priority-return-dialogue') {
|
||||
return {
|
||||
...evidence,
|
||||
unitIds: [...evidence.unitIds]
|
||||
};
|
||||
}
|
||||
return { ...evidence };
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown) {
|
||||
return (
|
||||
typeof value === 'number' &&
|
||||
Number.isInteger(value) &&
|
||||
value > 0 &&
|
||||
value <= 9999
|
||||
)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown) {
|
||||
return (
|
||||
typeof value === 'number' &&
|
||||
Number.isFinite(value) &&
|
||||
value > 0
|
||||
)
|
||||
? Math.min(999999, Math.floor(value))
|
||||
: 0;
|
||||
}
|
||||
|
||||
function arrayValue(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,12 @@ import {
|
||||
thirdBattleReturnCampStep,
|
||||
thirdBattleReturnSourceBattleId
|
||||
} from '../data/thirdBattleReturnDialogue';
|
||||
import {
|
||||
thirdCampPreparationSourceBattleId,
|
||||
thirdCampPreparationTargetBattleId,
|
||||
type ThirdCampPreparationPriorityId,
|
||||
type ThirdCampPreparationUnavailableReason
|
||||
} from '../data/thirdCampPreparation';
|
||||
import {
|
||||
findCityStayAfterBattle,
|
||||
type CityEquipmentOffer,
|
||||
@@ -242,6 +248,10 @@ import {
|
||||
purchaseCityStayEquipment
|
||||
} from '../state/cityStayActions';
|
||||
import { secondBattleReliefProgress } from '../state/secondBattleReliefActions';
|
||||
import {
|
||||
setThirdCampPreparationPriority,
|
||||
thirdCampPreparationProgress
|
||||
} from '../state/thirdCampPreparationActions';
|
||||
import { clearCampaignBattleSavesForSlot } from '../state/battleSaveKeys';
|
||||
import { releaseTextureSource } from '../render/loaderLifecycle';
|
||||
import { palette } from '../ui/palette';
|
||||
@@ -412,6 +422,15 @@ type SortiePortraitRosterLayout = {
|
||||
nextEnabled: boolean;
|
||||
};
|
||||
|
||||
type ThirdCampPreparationCardView = {
|
||||
priorityId: ThirdCampPreparationPriorityId;
|
||||
selectable: boolean;
|
||||
selected: boolean;
|
||||
unavailableReason?: ThirdCampPreparationUnavailableReason;
|
||||
background: Phaser.GameObjects.Rectangle;
|
||||
actionButton: Phaser.GameObjects.Rectangle;
|
||||
};
|
||||
|
||||
type CampSupplyId = 'bean' | 'wine' | 'salve';
|
||||
|
||||
type CampSupplyDefinition = {
|
||||
@@ -11475,6 +11494,10 @@ export class CampScene extends Phaser.Scene {
|
||||
private firstBattleFollowupCtaButton?: Phaser.GameObjects.Rectangle;
|
||||
private firstBattleFollowupCard?: Phaser.GameObjects.Rectangle;
|
||||
private firstBattleFollowupSummaryText?: Phaser.GameObjects.Text;
|
||||
private thirdCampPreparationBrowserOpen = false;
|
||||
private thirdCampPreparationToggleButton?: Phaser.GameObjects.Rectangle;
|
||||
private thirdCampPreparationCloseButton?: Phaser.GameObjects.Rectangle;
|
||||
private thirdCampPreparationCardViews: ThirdCampPreparationCardView[] = [];
|
||||
private visitedTabs = new Set<CampTab>();
|
||||
private selectedSortieUnitIds: string[] = [];
|
||||
private sortieFormationAssignments: SortieFormationAssignments = {};
|
||||
@@ -11526,6 +11549,7 @@ export class CampScene extends Phaser.Scene {
|
||||
|
||||
init(data?: CampSceneData) {
|
||||
this.ownedCampTextureKeys.clear();
|
||||
this.thirdCampPreparationBrowserOpen = false;
|
||||
if (data?.completedAftermathBattleId) {
|
||||
completeCampaignAftermath(data.completedAftermathBattleId);
|
||||
}
|
||||
@@ -13889,6 +13913,13 @@ export class CampScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.sortieObjects.length > 0 && this.thirdCampPreparationBrowserOpen) {
|
||||
soundDirector.playSelect();
|
||||
this.thirdCampPreparationBrowserOpen = false;
|
||||
this.showSortiePrep();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.sortieObjects.length > 0 && this.sortieFormationPanelMode !== 'roster') {
|
||||
soundDirector.playSelect();
|
||||
this.closeSortieFormationPanelBrowser();
|
||||
@@ -13912,6 +13943,7 @@ export class CampScene extends Phaser.Scene {
|
||||
this.equipmentSwapConfirmObjects.length > 0 ||
|
||||
this.saveSlotObjects.length > 0 ||
|
||||
this.saveSlotConfirmObjects.length > 0 ||
|
||||
this.thirdCampPreparationBrowserOpen ||
|
||||
!this.usesStagedSortiePrep()
|
||||
) {
|
||||
return;
|
||||
@@ -14248,6 +14280,13 @@ export class CampScene extends Phaser.Scene {
|
||||
return flow.afterBattleId === campBattleIds.first && flow.nextBattleId === campBattleIds.second;
|
||||
}
|
||||
|
||||
private isThirdCampPreparationSlice(flow = this.currentSortieFlow()) {
|
||||
return (
|
||||
flow.afterBattleId === thirdCampPreparationSourceBattleId &&
|
||||
flow.nextBattleId === thirdCampPreparationTargetBattleId
|
||||
);
|
||||
}
|
||||
|
||||
private usesStagedSortiePrep(flow = this.currentSortieFlow()) {
|
||||
return Boolean(flow.nextBattleId);
|
||||
}
|
||||
@@ -14355,8 +14394,16 @@ export class CampScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private renderFirstSortieTacticalNotes(x: number, y: number, width: number, height: number, depth: number) {
|
||||
if (this.isThirdCampPreparationSlice() && this.thirdCampPreparationBrowserOpen) {
|
||||
this.renderThirdCampPreparationBrowser(x, y, width, height, depth);
|
||||
return;
|
||||
}
|
||||
|
||||
const scenario = this.nextSortieScenario();
|
||||
const firstSortie = this.isFirstSortiePrepSlice();
|
||||
const thirdCampPreparation = this.isThirdCampPreparationSlice() && this.campaign
|
||||
? thirdCampPreparationProgress(this.campaign)
|
||||
: undefined;
|
||||
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.94));
|
||||
bg.setOrigin(0);
|
||||
bg.setDepth(depth);
|
||||
@@ -14412,26 +14459,46 @@ export class CampScene extends Phaser.Scene {
|
||||
if (firstBattleFollowup) {
|
||||
this.firstBattleFollowupCard = command;
|
||||
}
|
||||
const commandTitle = firstBattleFollowup
|
||||
? `지난 전투 인계 · ${firstBattleFollowup.mentorName}`
|
||||
: '지휘관 판단';
|
||||
const commandTitle = thirdCampPreparation
|
||||
? thirdCampPreparation.ready
|
||||
? `출진 우선 · ${thirdCampPreparation.selectedLabel}`
|
||||
: '출진 우선 · 하나를 선택'
|
||||
: firstBattleFollowup
|
||||
? `지난 전투 인계 · ${firstBattleFollowup.mentorName}`
|
||||
: '지휘관 판단';
|
||||
this.trackSortie(this.add.text(x + 34, commandY + 12, commandTitle, this.textStyle(14, '#ffdf7b', true))).setDepth(depth + 2);
|
||||
const commandSummary = firstBattleFollowup
|
||||
? firstBattleFollowup.achieved
|
||||
? `${firstBattleFollowup.originalOrderLabel} 완수 → ${firstBattleFollowup.recommendedNextOrderLabel} · ${firstBattleFollowup.focusCriterionLabel}`
|
||||
: `${firstBattleFollowup.originalOrderLabel} 보완 → ${firstBattleFollowup.focusCriterionLabel}`
|
||||
: scenario.tacticalGuide?.summary ?? scenario.openingObjectiveLines[1] ?? (firstSortie
|
||||
? '세 형제가 서로 다른 길을 맡아 적의 전열을 흔드십시오.'
|
||||
: '전열·돌파·후원의 균형으로 적의 주력을 나누십시오.');
|
||||
const selectedPreparation = thirdCampPreparation?.availability.find(
|
||||
(entry) => entry.priority.id === thirdCampPreparation.selectedPriorityId
|
||||
);
|
||||
const commandSummary = thirdCampPreparation
|
||||
? selectedPreparation?.priority.summary ??
|
||||
'실제로 확인한 정보·장비·동료 활동 가운데 이번 출진에 먼저 반영할 하나를 정하십시오.'
|
||||
: firstBattleFollowup
|
||||
? firstBattleFollowup.achieved
|
||||
? `${firstBattleFollowup.originalOrderLabel} 완수 → ${firstBattleFollowup.recommendedNextOrderLabel} · ${firstBattleFollowup.focusCriterionLabel}`
|
||||
: `${firstBattleFollowup.originalOrderLabel} 보완 → ${firstBattleFollowup.focusCriterionLabel}`
|
||||
: scenario.tacticalGuide?.summary ?? scenario.openingObjectiveLines[1] ?? (firstSortie
|
||||
? '세 형제가 서로 다른 길을 맡아 적의 전열을 흔드십시오.'
|
||||
: '전열·돌파·후원의 균형으로 적의 주력을 나누십시오.');
|
||||
const commandSummaryText = this.trackSortie(
|
||||
this.add.text(x + 34, commandY + 36, commandSummary, {
|
||||
...this.textStyle(firstBattleFollowup ? 11 : 13, '#d4dce6'),
|
||||
wordWrap: { width: firstBattleFollowup ? width - 214 : width - 68, useAdvancedWrap: true },
|
||||
lineSpacing: firstBattleFollowup ? 2 : 3
|
||||
...this.textStyle(firstBattleFollowup || thirdCampPreparation ? 11 : 13, '#d4dce6'),
|
||||
wordWrap: {
|
||||
width: firstBattleFollowup || thirdCampPreparation ? width - 214 : width - 68,
|
||||
useAdvancedWrap: true
|
||||
},
|
||||
lineSpacing: firstBattleFollowup || thirdCampPreparation ? 2 : 3
|
||||
})
|
||||
);
|
||||
commandSummaryText.setDepth(depth + 2);
|
||||
if (firstBattleFollowup) {
|
||||
if (thirdCampPreparation) {
|
||||
this.renderThirdCampPreparationToggle(
|
||||
x + width - 86,
|
||||
commandY + 51,
|
||||
thirdCampPreparation.ready ? '준비 변경' : '준비 선택',
|
||||
depth + 2
|
||||
);
|
||||
} else if (firstBattleFollowup) {
|
||||
this.firstBattleFollowupSummaryText = commandSummaryText;
|
||||
}
|
||||
if (firstBattleFollowup) {
|
||||
@@ -14463,6 +14530,320 @@ export class CampScene extends Phaser.Scene {
|
||||
}
|
||||
}
|
||||
|
||||
private renderThirdCampPreparationToggle(
|
||||
x: number,
|
||||
y: number,
|
||||
label: string,
|
||||
depth: number
|
||||
) {
|
||||
const width = 132;
|
||||
const button = this.trackSortie(
|
||||
this.add.rectangle(x, y, width, 34, 0x4a371d, 0.98)
|
||||
);
|
||||
button.setDepth(depth);
|
||||
button.setStrokeStyle(2, palette.gold, 0.92);
|
||||
button.setInteractive({ useHandCursor: true });
|
||||
const open = () => {
|
||||
this.thirdCampPreparationBrowserOpen = true;
|
||||
soundDirector.playSelect();
|
||||
this.showSortiePrep();
|
||||
};
|
||||
button.on('pointerover', () => button.setFillStyle(0x654c25, 1));
|
||||
button.on('pointerout', () => button.setFillStyle(0x4a371d, 0.98));
|
||||
button.on('pointerdown', open);
|
||||
this.thirdCampPreparationToggleButton = button;
|
||||
|
||||
const text = this.trackSortie(
|
||||
this.add.text(x, y, label, this.textStyle(11, '#fff2b8', true))
|
||||
);
|
||||
text.setOrigin(0.5);
|
||||
text.setDepth(depth + 1);
|
||||
text.setInteractive({ useHandCursor: true });
|
||||
text.on('pointerover', () => button.setFillStyle(0x654c25, 1));
|
||||
text.on('pointerout', () => button.setFillStyle(0x4a371d, 0.98));
|
||||
text.on('pointerdown', open);
|
||||
}
|
||||
|
||||
private renderThirdCampPreparationBrowser(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
depth: number
|
||||
) {
|
||||
const progress = this.campaign
|
||||
? thirdCampPreparationProgress(this.campaign)
|
||||
: undefined;
|
||||
const background = this.trackSortie(
|
||||
this.add.rectangle(x, y, width, height, 0x0d141c, 0.98)
|
||||
);
|
||||
background.setOrigin(0);
|
||||
background.setDepth(depth);
|
||||
background.setStrokeStyle(1, palette.gold, 0.72);
|
||||
|
||||
this.trackSortie(
|
||||
this.add.text(x + 18, y + 14, '이번 출진의 우선 준비', this.textStyle(19, '#f2e3bf', true))
|
||||
).setDepth(depth + 1);
|
||||
this.trackSortie(
|
||||
this.add.text(
|
||||
x + 18,
|
||||
y + 41,
|
||||
'실제로 확인한 활동 하나를 광종 본영전 첫 3턴에 반영합니다.',
|
||||
this.textStyle(10, '#9fb0bf')
|
||||
)
|
||||
).setDepth(depth + 1);
|
||||
|
||||
const close = this.trackSortie(
|
||||
this.add.rectangle(x + width - 29, y + 25, 38, 26, 0x1a2630, 0.96)
|
||||
);
|
||||
close.setDepth(depth + 1);
|
||||
close.setStrokeStyle(1, palette.blue, 0.68);
|
||||
close.setInteractive({ useHandCursor: true });
|
||||
const closeBrowser = () => {
|
||||
this.thirdCampPreparationBrowserOpen = false;
|
||||
soundDirector.playSelect();
|
||||
this.showSortiePrep();
|
||||
};
|
||||
close.on('pointerover', () => close.setFillStyle(0x283947, 1));
|
||||
close.on('pointerout', () => close.setFillStyle(0x1a2630, 0.96));
|
||||
close.on('pointerdown', closeBrowser);
|
||||
this.thirdCampPreparationCloseButton = close;
|
||||
const closeLabel = this.trackSortie(
|
||||
this.add.text(x + width - 29, y + 25, '×', this.textStyle(17, '#f2e3bf', true))
|
||||
);
|
||||
closeLabel.setOrigin(0.5);
|
||||
closeLabel.setDepth(depth + 2);
|
||||
closeLabel.setInteractive({ useHandCursor: true });
|
||||
closeLabel.on('pointerdown', closeBrowser);
|
||||
|
||||
if (!progress) {
|
||||
this.trackSortie(
|
||||
this.add.text(
|
||||
x + width / 2,
|
||||
y + height / 2,
|
||||
'현재 출진에서는 우선 준비를 선택할 수 없습니다.',
|
||||
this.textStyle(12, '#9fb0bf', true)
|
||||
)
|
||||
).setOrigin(0.5).setDepth(depth + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const sealByPriority: Record<ThirdCampPreparationPriorityId, string> = {
|
||||
information: '보',
|
||||
equipment: '장',
|
||||
companion: '동'
|
||||
};
|
||||
const toneByPriority: Record<ThirdCampPreparationPriorityId, number> = {
|
||||
information: palette.blue,
|
||||
equipment: palette.gold,
|
||||
companion: 0xc887dc
|
||||
};
|
||||
progress.availability.forEach((entry, index) => {
|
||||
const priorityId = entry.priority.id;
|
||||
const selected = progress.selectedPriorityId === priorityId;
|
||||
const cardY = y + 68 + index * 106;
|
||||
const cardHeight = 98;
|
||||
const fill = selected
|
||||
? 0x183126
|
||||
: entry.selectable
|
||||
? 0x17232e
|
||||
: 0x111820;
|
||||
const tone = selected ? palette.green : toneByPriority[priorityId];
|
||||
const card = this.trackSortie(
|
||||
this.add.rectangle(x + 16, cardY, width - 32, cardHeight, fill, entry.selectable ? 0.98 : 0.78)
|
||||
);
|
||||
card.setOrigin(0);
|
||||
card.setDepth(depth + 1);
|
||||
card.setStrokeStyle(selected ? 2 : 1, tone, selected ? 0.96 : entry.selectable ? 0.68 : 0.28);
|
||||
|
||||
const seal = this.trackSortie(
|
||||
this.add.circle(x + 43, cardY + 31, 17, tone, entry.selectable ? 0.9 : 0.38)
|
||||
);
|
||||
seal.setDepth(depth + 2);
|
||||
seal.setStrokeStyle(1, selected ? 0xa8ffd0 : 0xf2e3bf, entry.selectable ? 0.72 : 0.24);
|
||||
this.trackSortie(
|
||||
this.add.text(x + 43, cardY + 31, sealByPriority[priorityId], this.textStyle(13, '#f2e3bf', true))
|
||||
).setOrigin(0.5).setDepth(depth + 3);
|
||||
this.trackSortie(
|
||||
this.add.text(
|
||||
x + 68,
|
||||
cardY + 10,
|
||||
`${entry.priority.label} · ${entry.priority.activityLabel}`,
|
||||
this.textStyle(13, entry.selectable ? '#ffdf7b' : '#77818c', true)
|
||||
)
|
||||
).setDepth(depth + 2);
|
||||
this.trackSortie(
|
||||
this.add.text(
|
||||
x + 68,
|
||||
cardY + 33,
|
||||
entry.selectable
|
||||
? entry.priority.summary
|
||||
: this.thirdCampPreparationUnavailableLabel(entry.unavailableReason),
|
||||
{
|
||||
...this.textStyle(10, entry.selectable ? '#d4dce6' : '#7f8994'),
|
||||
wordWrap: { width: width - 172, useAdvancedWrap: true },
|
||||
lineSpacing: 2,
|
||||
maxLines: 3
|
||||
}
|
||||
)
|
||||
).setDepth(depth + 2);
|
||||
|
||||
const actionX = x + width - 57;
|
||||
const actionY = cardY + cardHeight / 2;
|
||||
const action = this.trackSortie(
|
||||
this.add.rectangle(
|
||||
actionX,
|
||||
actionY,
|
||||
82,
|
||||
32,
|
||||
selected ? 0x28533c : entry.selectable ? 0x4a371d : 0x1a2630,
|
||||
entry.selectable ? 0.98 : 0.8
|
||||
)
|
||||
);
|
||||
action.setDepth(depth + 2);
|
||||
action.setStrokeStyle(1, tone, entry.selectable ? 0.82 : 0.34);
|
||||
action.setInteractive({ useHandCursor: true });
|
||||
const actionLabel = this.trackSortie(
|
||||
this.add.text(
|
||||
actionX,
|
||||
actionY,
|
||||
selected
|
||||
? '선택됨'
|
||||
: entry.selectable
|
||||
? '선택'
|
||||
: this.thirdCampPreparationLockedActionLabel(priorityId),
|
||||
this.textStyle(10, entry.selectable ? '#fff2b8' : '#9fb0bf', true)
|
||||
)
|
||||
);
|
||||
actionLabel.setOrigin(0.5);
|
||||
actionLabel.setDepth(depth + 3);
|
||||
actionLabel.setInteractive({ useHandCursor: true });
|
||||
|
||||
const run = () => {
|
||||
if (entry.selectable) {
|
||||
this.selectThirdCampPreparationPriority(priorityId);
|
||||
return;
|
||||
}
|
||||
this.openThirdCampPreparationActivity(priorityId);
|
||||
};
|
||||
[card, action, actionLabel].forEach((target) => {
|
||||
target.setInteractive({ useHandCursor: true });
|
||||
target.on('pointerdown', run);
|
||||
});
|
||||
action.on('pointerover', () =>
|
||||
action.setFillStyle(entry.selectable ? 0x654c25 : 0x283947, 1)
|
||||
);
|
||||
action.on('pointerout', () =>
|
||||
action.setFillStyle(
|
||||
selected ? 0x28533c : entry.selectable ? 0x4a371d : 0x1a2630,
|
||||
entry.selectable ? 0.98 : 0.8
|
||||
)
|
||||
);
|
||||
this.thirdCampPreparationCardViews.push({
|
||||
priorityId,
|
||||
selectable: entry.selectable,
|
||||
selected,
|
||||
...(entry.unavailableReason
|
||||
? { unavailableReason: entry.unavailableReason }
|
||||
: {}),
|
||||
background: card,
|
||||
actionButton: action
|
||||
});
|
||||
});
|
||||
|
||||
this.trackSortie(
|
||||
this.add.text(
|
||||
x + 18,
|
||||
y + height - 25,
|
||||
'권장 선택 · 출진 전에는 언제든 다른 완료 활동으로 바꿀 수 있습니다.',
|
||||
this.textStyle(10, progress.ready ? '#a8ffd0' : '#ffdf7b', true)
|
||||
)
|
||||
).setDepth(depth + 1);
|
||||
}
|
||||
|
||||
private thirdCampPreparationUnavailableLabel(
|
||||
reason?: ThirdCampPreparationUnavailableReason
|
||||
) {
|
||||
if (reason === 'report-review-required') {
|
||||
return '승전 보고에서 광종 본영 진입 정보를 먼저 확인해야 합니다.';
|
||||
}
|
||||
if (reason === 'equipment-change-required') {
|
||||
return '3차 승리로 받은 장비를 장비 탭에서 먼저 확인해야 합니다.';
|
||||
}
|
||||
if (reason === 'priority-dialogue-required') {
|
||||
return '전투 결과에 맞춰 표시된 우선 귀환 대화를 먼저 마쳐야 합니다.';
|
||||
}
|
||||
return '광종 구원로 승리 기록이 있어야 선택할 수 있습니다.';
|
||||
}
|
||||
|
||||
private thirdCampPreparationLockedActionLabel(
|
||||
priorityId: ThirdCampPreparationPriorityId
|
||||
) {
|
||||
if (priorityId === 'equipment') {
|
||||
return '장비 보기';
|
||||
}
|
||||
if (priorityId === 'companion') {
|
||||
return '대화하기';
|
||||
}
|
||||
return '보고 확인';
|
||||
}
|
||||
|
||||
private selectThirdCampPreparationPriority(
|
||||
priorityId: ThirdCampPreparationPriorityId
|
||||
) {
|
||||
const result = setThirdCampPreparationPriority(priorityId);
|
||||
if (!result.ok) {
|
||||
const message = result.reason === 'activity-required'
|
||||
? '관련 활동을 먼저 마쳐야 이 준비를 선택할 수 있습니다.'
|
||||
: '현재 진행에서는 이 준비를 저장할 수 없습니다.';
|
||||
this.showCampNotice(message);
|
||||
return;
|
||||
}
|
||||
|
||||
this.campaign = result.campaign;
|
||||
this.thirdCampPreparationBrowserOpen = true;
|
||||
soundDirector.playSelect();
|
||||
this.showCampNotice(
|
||||
`${result.priority.label} 선택 · ${result.memory.activeLine}`
|
||||
);
|
||||
this.showSortiePrep();
|
||||
}
|
||||
|
||||
private openThirdCampPreparationActivity(
|
||||
priorityId: ThirdCampPreparationPriorityId
|
||||
) {
|
||||
if (priorityId === 'equipment') {
|
||||
this.thirdCampPreparationBrowserOpen = false;
|
||||
this.hideSortiePrep();
|
||||
this.activeTab = 'equipment';
|
||||
this.render();
|
||||
this.showCampNotice('3차 승전 장비를 확인했습니다. 출진 준비에서 장비 정비를 선택할 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (priorityId === 'companion') {
|
||||
const priorityDialogue = this.thirdBattlePriorityReturnDialogue();
|
||||
if (!priorityDialogue) {
|
||||
this.showCampNotice('현재 승리 기록에서 우선 귀환 대화를 확인할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
this.thirdCampPreparationBrowserOpen = false;
|
||||
this.hideSortiePrep();
|
||||
this.activeTab = 'dialogue';
|
||||
this.selectedDialogueId = priorityDialogue.targetDialogueId;
|
||||
this.render();
|
||||
this.showCampNotice('표시된 우선 귀환 대화를 마치면 동료 공명을 선택할 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.acknowledgePendingVictoryRewardCategories(['unlocks']);
|
||||
this.campaign = getCampaignState();
|
||||
this.thirdCampPreparationBrowserOpen = true;
|
||||
soundDirector.playSelect();
|
||||
this.showCampNotice('승전 보고의 광종 본영 진입 정보를 확인했습니다.');
|
||||
this.showSortiePrep();
|
||||
}
|
||||
|
||||
private openFirstBattleFollowupOrderBrowser() {
|
||||
if (!this.firstBattleCampFollowup() || !this.nextSortieScenario()) {
|
||||
return;
|
||||
@@ -20211,6 +20592,9 @@ export class CampScene extends Phaser.Scene {
|
||||
const roleDetail = hasBattle
|
||||
? `전열 ${roleCounts.front} · 돌파 ${roleCounts.flank} · 후원 ${roleCounts.support}`
|
||||
: `대표 ${roleCounts.front} · 현장 ${roleCounts.flank} · 보좌 ${roleCounts.support}`;
|
||||
const thirdCampPreparation = this.isThirdCampPreparationSlice() && this.campaign
|
||||
? thirdCampPreparationProgress(this.campaign)
|
||||
: undefined;
|
||||
return [
|
||||
{
|
||||
label: requiredLabel,
|
||||
@@ -20218,6 +20602,16 @@ export class CampScene extends Phaser.Scene {
|
||||
detail: requiredDetail,
|
||||
priority: 'required'
|
||||
},
|
||||
...(thirdCampPreparation
|
||||
? [{
|
||||
label: '출진 우선 준비',
|
||||
complete: thirdCampPreparation.ready,
|
||||
detail: thirdCampPreparation.ready
|
||||
? `${thirdCampPreparation.selectedLabel} · 첫 3턴 반영`
|
||||
: '정보·장비·동료 중 완료한 활동 하나 선택',
|
||||
priority: 'recommended' as const
|
||||
}]
|
||||
: []),
|
||||
{
|
||||
label: '부상 장수 확인',
|
||||
complete: recoveryTargets.length === 0,
|
||||
@@ -20587,6 +20981,9 @@ export class CampScene extends Phaser.Scene {
|
||||
this.firstBattleFollowupCtaButton = undefined;
|
||||
this.firstBattleFollowupCard = undefined;
|
||||
this.firstBattleFollowupSummaryText = undefined;
|
||||
this.thirdCampPreparationToggleButton = undefined;
|
||||
this.thirdCampPreparationCloseButton = undefined;
|
||||
this.thirdCampPreparationCardViews = [];
|
||||
this.sortiePortraitRosterLayout = undefined;
|
||||
this.sortieRosterToggleBounds = [];
|
||||
this.sortieHoveredUnitId = undefined;
|
||||
@@ -20597,6 +20994,7 @@ export class CampScene extends Phaser.Scene {
|
||||
this.sortieFormationPanelMode = 'roster';
|
||||
this.sortieCoreResonanceSelectorOpen = false;
|
||||
this.sortieCoreResonancePage = 0;
|
||||
this.thirdCampPreparationBrowserOpen = false;
|
||||
}
|
||||
this.sortieComparisonPanelView = undefined;
|
||||
this.sortiePursuitPanelView = undefined;
|
||||
@@ -25285,6 +25683,9 @@ export class CampScene extends Phaser.Scene {
|
||||
: undefined;
|
||||
const secondReliefChoiceId =
|
||||
this.campaign?.campVisitChoiceIds[secondBattleReliefVisitId] ?? null;
|
||||
const thirdCampPreparation = this.campaign && this.isThirdCampPreparationSlice()
|
||||
? thirdCampPreparationProgress(this.campaign)
|
||||
: undefined;
|
||||
const availableCampDialogues = this.availableCampDialogues();
|
||||
const selectedCampDialogue = availableCampDialogues.find(
|
||||
(dialogue) => dialogue.id === this.selectedDialogueId
|
||||
@@ -25470,6 +25871,63 @@ export class CampScene extends Phaser.Scene {
|
||||
)
|
||||
}
|
||||
: null,
|
||||
thirdCampPreparation: thirdCampPreparation
|
||||
? {
|
||||
available: true,
|
||||
sourceBattleId: thirdCampPreparation.sourceBattleId,
|
||||
targetBattleId: thirdCampPreparation.targetBattleId,
|
||||
selectionRecordId: thirdCampPreparation.selectionRecordId,
|
||||
ready: thirdCampPreparation.ready,
|
||||
selectedPriorityId: thirdCampPreparation.selectedPriorityId,
|
||||
selectedLabel: thirdCampPreparation.selectedLabel,
|
||||
browserOpen: this.thirdCampPreparationBrowserOpen,
|
||||
toggleButtonBounds: this.sortieInteractiveObjectBoundsDebug(
|
||||
this.thirdCampPreparationToggleButton
|
||||
),
|
||||
closeButtonBounds: this.sortieInteractiveObjectBoundsDebug(
|
||||
this.thirdCampPreparationCloseButton
|
||||
),
|
||||
priorities: thirdCampPreparation.availability.map((entry) => {
|
||||
const view = this.thirdCampPreparationCardViews.find(
|
||||
(candidate) => candidate.priorityId === entry.priority.id
|
||||
);
|
||||
return {
|
||||
id: entry.priority.id,
|
||||
label: entry.priority.label,
|
||||
activityLabel: entry.priority.activityLabel,
|
||||
summary: entry.priority.summary,
|
||||
selectable: entry.selectable,
|
||||
selected: thirdCampPreparation.selectedPriorityId === entry.priority.id,
|
||||
evidenceKind: entry.evidence?.kind ?? null,
|
||||
evidence: entry.evidence
|
||||
? entry.evidence.kind === 'priority-return-dialogue'
|
||||
? {
|
||||
...entry.evidence,
|
||||
unitIds: [...entry.evidence.unitIds]
|
||||
}
|
||||
: { ...entry.evidence }
|
||||
: null,
|
||||
unavailableReason: entry.unavailableReason ?? null,
|
||||
cardBounds: this.sortieObjectBoundsDebug(view?.background),
|
||||
actionButtonBounds: this.sortieInteractiveObjectBoundsDebug(
|
||||
view?.actionButton
|
||||
)
|
||||
};
|
||||
})
|
||||
}
|
||||
: {
|
||||
available: false,
|
||||
sourceBattleId: thirdCampPreparationSourceBattleId,
|
||||
targetBattleId: thirdCampPreparationTargetBattleId,
|
||||
selectionRecordId: null,
|
||||
ready: false,
|
||||
selectedPriorityId: null,
|
||||
selectedLabel: null,
|
||||
browserOpen: false,
|
||||
toggleButtonBounds: null,
|
||||
closeButtonBounds: null,
|
||||
priorities: []
|
||||
},
|
||||
firstPursuitScoutMemory: {
|
||||
visitId: firstPursuitScoutVisitId,
|
||||
available: Boolean(firstPursuitScoutVisit),
|
||||
|
||||
@@ -9,6 +9,14 @@ import type { UnitDirection } from '../data/unitAssets';
|
||||
import { equipmentExpToNext, equipmentSlots, type EquipmentSlot } from '../data/battleItems';
|
||||
import type { SortieOrderId } from '../data/sortieOrders';
|
||||
import { coreSortieResonanceMinLevel } from '../data/sortieSynergy';
|
||||
import {
|
||||
getThirdCampPreparationPriority,
|
||||
isThirdCampPreparationPriorityId,
|
||||
thirdCampPreparationInformationEnemyUnitId,
|
||||
thirdCampPreparationSelectionRecordId,
|
||||
thirdCampPreparationTargetBattleId,
|
||||
type ThirdCampPreparationPriorityId
|
||||
} from '../data/thirdCampPreparation';
|
||||
|
||||
export type BattleSaveFaction = 'ally' | 'enemy';
|
||||
export type BattleSaveRosterTab = 'ally' | 'enemy';
|
||||
@@ -91,6 +99,19 @@ export type BattleSaveCooperationStats = {
|
||||
additionalDamage: number;
|
||||
};
|
||||
|
||||
export type BattleSaveThirdCampPreparationState = {
|
||||
selectionRecordId: typeof thirdCampPreparationSelectionRecordId;
|
||||
priorityId: ThirdCampPreparationPriorityId;
|
||||
label: string;
|
||||
markedEnemyUnitId?: typeof thirdCampPreparationInformationEnemyUnitId;
|
||||
informationConsumed: boolean;
|
||||
equipmentConsumed: boolean;
|
||||
companionAttempts: number;
|
||||
companionSuccesses: number;
|
||||
usageCount: number;
|
||||
preventedDamage: number;
|
||||
};
|
||||
|
||||
export type BattleSaveEventPriority = 'critical' | 'high' | 'normal' | 'low';
|
||||
|
||||
export type BattleSavePendingEvent = {
|
||||
@@ -111,6 +132,7 @@ export type BattleSaveState = {
|
||||
sortieRecommendation?: CampaignSortieRecommendationSnapshot;
|
||||
coreResonanceStats?: BattleSaveCoreResonanceStats;
|
||||
cooperationStatsByBond?: Record<string, BattleSaveCooperationStats>;
|
||||
thirdCampPreparation?: BattleSaveThirdCampPreparationState;
|
||||
savedAt: string;
|
||||
turnNumber: number;
|
||||
activeFaction: BattleSaveFaction;
|
||||
@@ -207,6 +229,7 @@ export function normalizeBattleSaveState(
|
||||
|
||||
normalizeCoreResonanceSaveFields(cloned, options);
|
||||
normalizeCooperationSaveFields(cloned, options);
|
||||
normalizeThirdCampPreparationSaveField(cloned);
|
||||
const recommendation = normalizeCampaignSortieRecommendationSnapshot(cloned.sortieRecommendation, {
|
||||
expectedBattleId: typeof cloned.battleId === 'string' ? cloned.battleId : options.expectedBattleId,
|
||||
allowedUnitIds: options.validAllyUnitIds
|
||||
@@ -256,6 +279,10 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isOptionalThirdCampPreparationState(state.thirdCampPreparation, state)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const attackIntents = state.attackIntents;
|
||||
const units = state.units;
|
||||
|
||||
@@ -505,6 +532,141 @@ function isOptionalCooperationStatsByBond(
|
||||
));
|
||||
}
|
||||
|
||||
function normalizeThirdCampPreparationSaveField(
|
||||
state: Record<string, unknown>
|
||||
) {
|
||||
if (
|
||||
state.battleId !== thirdCampPreparationTargetBattleId ||
|
||||
!isRecord(state.thirdCampPreparation)
|
||||
) {
|
||||
delete state.thirdCampPreparation;
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = state.thirdCampPreparation;
|
||||
const priorityId = isThirdCampPreparationPriorityId(raw.priorityId)
|
||||
? raw.priorityId
|
||||
: undefined;
|
||||
const priority = priorityId
|
||||
? getThirdCampPreparationPriority(priorityId)
|
||||
: undefined;
|
||||
if (
|
||||
!priorityId ||
|
||||
!priority ||
|
||||
raw.selectionRecordId !== thirdCampPreparationSelectionRecordId
|
||||
) {
|
||||
delete state.thirdCampPreparation;
|
||||
return;
|
||||
}
|
||||
|
||||
const informationConsumed =
|
||||
priorityId === 'information' && raw.informationConsumed === true;
|
||||
const equipmentConsumed =
|
||||
priorityId === 'equipment' && raw.equipmentConsumed === true;
|
||||
const companionAttempts = priorityId === 'companion'
|
||||
? normalizeCoreResonanceStatValue(raw.companionAttempts)
|
||||
: 0;
|
||||
const companionSuccesses = priorityId === 'companion'
|
||||
? Math.min(
|
||||
companionAttempts,
|
||||
normalizeCoreResonanceStatValue(raw.companionSuccesses)
|
||||
)
|
||||
: 0;
|
||||
const usageCount = priorityId === 'information'
|
||||
? Number(informationConsumed)
|
||||
: priorityId === 'equipment'
|
||||
? Number(equipmentConsumed)
|
||||
: companionAttempts;
|
||||
const preventedDamage = priorityId === 'equipment' && equipmentConsumed
|
||||
? normalizeCoreResonanceStatValue(raw.preventedDamage)
|
||||
: 0;
|
||||
|
||||
state.thirdCampPreparation = {
|
||||
selectionRecordId: thirdCampPreparationSelectionRecordId,
|
||||
priorityId,
|
||||
label: priority.label,
|
||||
...(priorityId === 'information'
|
||||
? {
|
||||
markedEnemyUnitId:
|
||||
thirdCampPreparationInformationEnemyUnitId
|
||||
}
|
||||
: {}),
|
||||
informationConsumed,
|
||||
equipmentConsumed,
|
||||
companionAttempts,
|
||||
companionSuccesses,
|
||||
usageCount,
|
||||
preventedDamage
|
||||
} satisfies BattleSaveThirdCampPreparationState;
|
||||
}
|
||||
|
||||
function isOptionalThirdCampPreparationState(
|
||||
value: unknown,
|
||||
state: Record<string, unknown>
|
||||
) {
|
||||
if (value === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
state.battleId !== thirdCampPreparationTargetBattleId ||
|
||||
!isRecord(value) ||
|
||||
value.selectionRecordId !==
|
||||
thirdCampPreparationSelectionRecordId ||
|
||||
!isThirdCampPreparationPriorityId(value.priorityId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const priority = getThirdCampPreparationPriority(value.priorityId);
|
||||
if (
|
||||
!priority ||
|
||||
value.label !== priority.label ||
|
||||
typeof value.informationConsumed !== 'boolean' ||
|
||||
typeof value.equipmentConsumed !== 'boolean' ||
|
||||
!isBattleUnitStatValue(value.companionAttempts) ||
|
||||
!isBattleUnitStatValue(value.companionSuccesses) ||
|
||||
!isBattleUnitStatValue(value.usageCount) ||
|
||||
!isBattleUnitStatValue(value.preventedDamage) ||
|
||||
Number(value.companionSuccesses) >
|
||||
Number(value.companionAttempts)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (value.priorityId === 'information') {
|
||||
return (
|
||||
value.markedEnemyUnitId ===
|
||||
thirdCampPreparationInformationEnemyUnitId &&
|
||||
value.equipmentConsumed === false &&
|
||||
Number(value.companionAttempts) === 0 &&
|
||||
Number(value.companionSuccesses) === 0 &&
|
||||
Number(value.usageCount) ===
|
||||
Number(value.informationConsumed) &&
|
||||
Number(value.preventedDamage) === 0
|
||||
);
|
||||
}
|
||||
if (value.priorityId === 'equipment') {
|
||||
return (
|
||||
value.markedEnemyUnitId === undefined &&
|
||||
value.informationConsumed === false &&
|
||||
Number(value.companionAttempts) === 0 &&
|
||||
Number(value.companionSuccesses) === 0 &&
|
||||
Number(value.usageCount) ===
|
||||
Number(value.equipmentConsumed) &&
|
||||
(
|
||||
value.equipmentConsumed === true ||
|
||||
Number(value.preventedDamage) === 0
|
||||
)
|
||||
);
|
||||
}
|
||||
return (
|
||||
value.markedEnemyUnitId === undefined &&
|
||||
value.informationConsumed === false &&
|
||||
value.equipmentConsumed === false &&
|
||||
Number(value.usageCount) ===
|
||||
Number(value.companionAttempts) &&
|
||||
Number(value.preventedDamage) === 0
|
||||
);
|
||||
}
|
||||
|
||||
function isOptionalCoreResonanceState(
|
||||
state: Record<string, unknown>,
|
||||
options: BattleSaveValidationOptions
|
||||
|
||||
@@ -2,6 +2,14 @@ import { equipmentExpToNext, equipmentSlots, itemCatalog, type EquipmentSlot } f
|
||||
import { unitClasses } from '../data/battleRules';
|
||||
import { battleScenarios, type BattleScenarioId } from '../data/battles';
|
||||
import { findCityStayAfterBattle, type CityStayId } from '../data/cityStays';
|
||||
import {
|
||||
isThirdCampPreparationPriorityId,
|
||||
resolveThirdCampPreparationAvailability,
|
||||
thirdCampPreparationSelectionRecordId,
|
||||
thirdCampPreparationSourceBattleId,
|
||||
thirdCampPreparationTargetBattleId,
|
||||
type ThirdCampPreparationPriorityId
|
||||
} from '../data/thirdCampPreparation';
|
||||
import {
|
||||
campaignRecruitUnits,
|
||||
jianYongRecruitBond,
|
||||
@@ -81,6 +89,12 @@ export type CampaignVictoryRewardAcknowledgements = Partial<Record<string, Campa
|
||||
|
||||
export type CampaignCampChoiceHistory = Record<string, string>;
|
||||
|
||||
export type ThirdCampPreparationSelectionSnapshot = {
|
||||
sourceBattleId: typeof thirdCampPreparationSourceBattleId;
|
||||
targetBattleId: typeof thirdCampPreparationTargetBattleId;
|
||||
priorityId: ThirdCampPreparationPriorityId;
|
||||
};
|
||||
|
||||
const campaignVictoryRewardCategoryIdSet = new Set<string>(campaignVictoryRewardCategoryIds);
|
||||
|
||||
export type CampaignSortieUnitPerformanceSnapshot = {
|
||||
@@ -527,6 +541,7 @@ export type CampaignState = {
|
||||
completedCampVisits: string[];
|
||||
campDialogueChoiceIds: CampaignCampChoiceHistory;
|
||||
campVisitChoiceIds: CampaignCampChoiceHistory;
|
||||
thirdCampPreparationSelection?: ThirdCampPreparationSelectionSnapshot;
|
||||
acknowledgedVictoryRewardBattleIds: string[];
|
||||
acknowledgedVictoryRewardCategories: CampaignVictoryRewardAcknowledgements;
|
||||
dismissedVictoryRewardNoticeBattleIds: string[];
|
||||
@@ -1707,6 +1722,16 @@ function normalizeCampaignState(state: Partial<CampaignState>): CampaignState {
|
||||
normalized.inventory = normalizeInventory(normalized.inventory);
|
||||
normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory);
|
||||
normalized.firstBattleReport = normalizeFirstBattleReport(normalized.firstBattleReport);
|
||||
delete normalized.campVisitChoiceIds[thirdCampPreparationSelectionRecordId];
|
||||
normalized.completedCampVisits = normalized.completedCampVisits.filter(
|
||||
(visitId) => visitId !== thirdCampPreparationSelectionRecordId
|
||||
);
|
||||
if (normalized.firstBattleReport) {
|
||||
normalized.firstBattleReport.completedCampVisits =
|
||||
normalized.firstBattleReport.completedCampVisits.filter(
|
||||
(visitId) => visitId !== thirdCampPreparationSelectionRecordId
|
||||
);
|
||||
}
|
||||
normalized.completedCampDialogues = mergeCampCompletionIds(
|
||||
normalized.completedCampDialogues,
|
||||
normalized.firstBattleReport?.completedCampDialogues ?? [],
|
||||
@@ -1852,6 +1877,13 @@ function normalizeCampaignState(state: Partial<CampaignState>): CampaignState {
|
||||
const available = campaignVictoryRewardCategoriesFor(source);
|
||||
return available.length > 0 && available.every((category) => acknowledged.has(category));
|
||||
});
|
||||
normalized.thirdCampPreparationSelection = normalizeThirdCampPreparationSelection(
|
||||
normalized.thirdCampPreparationSelection,
|
||||
normalized
|
||||
);
|
||||
if (!normalized.thirdCampPreparationSelection) {
|
||||
delete normalized.thirdCampPreparationSelection;
|
||||
}
|
||||
normalized.sortieOrderHistory = normalizeCampaignSortieOrderHistory(
|
||||
normalized.sortieOrderHistory,
|
||||
normalized.battleHistory,
|
||||
@@ -1904,6 +1936,33 @@ function normalizeCampChoiceHistory(value: unknown): CampaignCampChoiceHistory {
|
||||
return history;
|
||||
}
|
||||
|
||||
function normalizeThirdCampPreparationSelection(
|
||||
value: unknown,
|
||||
campaign: CampaignState
|
||||
): ThirdCampPreparationSelectionSnapshot | undefined {
|
||||
if (
|
||||
!isPlainObject(value) ||
|
||||
value.sourceBattleId !== thirdCampPreparationSourceBattleId ||
|
||||
value.targetBattleId !== thirdCampPreparationTargetBattleId ||
|
||||
!isThirdCampPreparationPriorityId(value.priorityId)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const priorityAvailable = resolveThirdCampPreparationAvailability(
|
||||
campaign
|
||||
).some(
|
||||
(entry) =>
|
||||
entry.priority.id === value.priorityId && entry.selectable
|
||||
);
|
||||
return priorityAvailable
|
||||
? {
|
||||
sourceBattleId: thirdCampPreparationSourceBattleId,
|
||||
targetBattleId: thirdCampPreparationTargetBattleId,
|
||||
priorityId: value.priorityId
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function uniqueCampHistoryIds(value: unknown): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
|
||||
210
src/game/state/thirdCampPreparationActions.ts
Normal file
210
src/game/state/thirdCampPreparationActions.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import {
|
||||
getThirdCampPreparationPriority,
|
||||
hasThirdCampPreparationSourceVictory,
|
||||
isThirdCampPreparationPriorityId,
|
||||
resolveThirdCampPreparationAvailability,
|
||||
resolveThirdCampPreparationMemory,
|
||||
resolveThirdCampPreparationSelection,
|
||||
thirdCampPreparationSelectionRecordId,
|
||||
thirdCampPreparationSourceBattleId,
|
||||
thirdCampPreparationTargetBattleId,
|
||||
type ThirdCampPreparationAvailability,
|
||||
type ThirdCampPreparationMemory,
|
||||
type ThirdCampPreparationPriorityDefinition,
|
||||
type ThirdCampPreparationPriorityId
|
||||
} from '../data/thirdCampPreparation';
|
||||
import {
|
||||
getCampaignState,
|
||||
setCampaignState,
|
||||
type CampaignState
|
||||
} from './campaignState';
|
||||
|
||||
export type ThirdCampPreparationSelectionFailure =
|
||||
| 'invalid-campaign'
|
||||
| 'invalid-priority'
|
||||
| 'activity-required'
|
||||
| 'save-unavailable';
|
||||
|
||||
export type ThirdCampPreparationSelectionResult =
|
||||
| {
|
||||
ok: true;
|
||||
priority: ThirdCampPreparationPriorityDefinition;
|
||||
memory: ThirdCampPreparationMemory;
|
||||
campaign: CampaignState;
|
||||
changed: boolean;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: ThirdCampPreparationSelectionFailure;
|
||||
};
|
||||
|
||||
export type ThirdCampPreparationClearResult =
|
||||
| {
|
||||
ok: true;
|
||||
campaign: CampaignState;
|
||||
changed: boolean;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: 'invalid-campaign' | 'save-unavailable';
|
||||
};
|
||||
|
||||
export function thirdCampPreparationProgress(
|
||||
campaign: CampaignState = getCampaignState()
|
||||
) {
|
||||
const availability = resolveThirdCampPreparationAvailability(
|
||||
campaign
|
||||
);
|
||||
const selection = resolveThirdCampPreparationSelection(campaign);
|
||||
return {
|
||||
sourceBattleId: thirdCampPreparationSourceBattleId,
|
||||
targetBattleId: thirdCampPreparationTargetBattleId,
|
||||
selectionRecordId: thirdCampPreparationSelectionRecordId,
|
||||
availability,
|
||||
selectedPriorityId: selection?.priorityId ?? null,
|
||||
selectedLabel: selection?.label ?? null,
|
||||
ready: selection?.selectable === true
|
||||
};
|
||||
}
|
||||
|
||||
export function setThirdCampPreparationPriority(
|
||||
priorityId: string
|
||||
): ThirdCampPreparationSelectionResult {
|
||||
let snapshot: CampaignState;
|
||||
try {
|
||||
snapshot = getCampaignState();
|
||||
} catch {
|
||||
return { ok: false, reason: 'save-unavailable' };
|
||||
}
|
||||
if (!campaignSupportsThirdCampPreparation(snapshot)) {
|
||||
return { ok: false, reason: 'invalid-campaign' };
|
||||
}
|
||||
if (!isThirdCampPreparationPriorityId(priorityId)) {
|
||||
return { ok: false, reason: 'invalid-priority' };
|
||||
}
|
||||
|
||||
const availability = availabilityFor(snapshot, priorityId);
|
||||
if (!availability?.selectable) {
|
||||
return { ok: false, reason: 'activity-required' };
|
||||
}
|
||||
const priority = getThirdCampPreparationPriority(priorityId);
|
||||
if (!priority) {
|
||||
return { ok: false, reason: 'invalid-priority' };
|
||||
}
|
||||
|
||||
const previousPriorityId =
|
||||
snapshot.thirdCampPreparationSelection?.priorityId;
|
||||
if (previousPriorityId === priorityId) {
|
||||
const memory = resolveThirdCampPreparationMemory({
|
||||
campaign: snapshot,
|
||||
battleId: thirdCampPreparationTargetBattleId
|
||||
});
|
||||
return memory
|
||||
? {
|
||||
ok: true,
|
||||
priority,
|
||||
memory,
|
||||
campaign: snapshot,
|
||||
changed: false
|
||||
}
|
||||
: { ok: false, reason: 'activity-required' };
|
||||
}
|
||||
|
||||
const updated: CampaignState = {
|
||||
...snapshot,
|
||||
thirdCampPreparationSelection: {
|
||||
sourceBattleId: thirdCampPreparationSourceBattleId,
|
||||
targetBattleId: thirdCampPreparationTargetBattleId,
|
||||
priorityId
|
||||
}
|
||||
};
|
||||
const persisted = persistSelection(updated, snapshot);
|
||||
if (!persisted) {
|
||||
return { ok: false, reason: 'save-unavailable' };
|
||||
}
|
||||
|
||||
const memory = resolveThirdCampPreparationMemory({
|
||||
campaign: persisted,
|
||||
battleId: thirdCampPreparationTargetBattleId
|
||||
});
|
||||
if (!memory || memory.priorityId !== priorityId) {
|
||||
restoreCampaignSnapshot(snapshot);
|
||||
return { ok: false, reason: 'save-unavailable' };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
priority,
|
||||
memory,
|
||||
campaign: persisted,
|
||||
changed: true
|
||||
};
|
||||
}
|
||||
|
||||
export function clearThirdCampPreparationPriority():
|
||||
ThirdCampPreparationClearResult {
|
||||
let snapshot: CampaignState;
|
||||
try {
|
||||
snapshot = getCampaignState();
|
||||
} catch {
|
||||
return { ok: false, reason: 'save-unavailable' };
|
||||
}
|
||||
if (!campaignSupportsThirdCampPreparation(snapshot)) {
|
||||
return { ok: false, reason: 'invalid-campaign' };
|
||||
}
|
||||
if (!snapshot.thirdCampPreparationSelection) {
|
||||
return {
|
||||
ok: true,
|
||||
campaign: snapshot,
|
||||
changed: false
|
||||
};
|
||||
}
|
||||
|
||||
const updated: CampaignState = {
|
||||
...snapshot
|
||||
};
|
||||
delete updated.thirdCampPreparationSelection;
|
||||
const persisted = persistSelection(updated, snapshot);
|
||||
return persisted
|
||||
? { ok: true, campaign: persisted, changed: true }
|
||||
: { ok: false, reason: 'save-unavailable' };
|
||||
}
|
||||
|
||||
function campaignSupportsThirdCampPreparation(
|
||||
campaign: CampaignState
|
||||
) {
|
||||
return (
|
||||
campaign.step === 'third-camp' &&
|
||||
campaign.latestBattleId === thirdCampPreparationSourceBattleId &&
|
||||
hasThirdCampPreparationSourceVictory(campaign)
|
||||
);
|
||||
}
|
||||
|
||||
function availabilityFor(
|
||||
campaign: CampaignState,
|
||||
priorityId: ThirdCampPreparationPriorityId
|
||||
): ThirdCampPreparationAvailability | undefined {
|
||||
return resolveThirdCampPreparationAvailability(campaign).find(
|
||||
(entry) => entry.priority.id === priorityId
|
||||
);
|
||||
}
|
||||
|
||||
function persistSelection(
|
||||
updated: CampaignState,
|
||||
snapshot: CampaignState
|
||||
) {
|
||||
try {
|
||||
return setCampaignState(updated);
|
||||
} catch {
|
||||
restoreCampaignSnapshot(snapshot);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreCampaignSnapshot(snapshot: CampaignState) {
|
||||
try {
|
||||
setCampaignState(snapshot);
|
||||
} catch {
|
||||
// setCampaignState restores the in-memory snapshot before persistence.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user