Files
heros_web/scripts/verify-third-camp-preparation.mjs

1352 lines
41 KiB
JavaScript

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 explorationDataModule = await server.ssrLoadModule(
'/src/game/data/thirdCampExploration.ts'
);
const explorationActionModule = await server.ssrLoadModule(
'/src/game/state/thirdCampExplorationActions.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,
explorationDataModule,
explorationActionModule,
campaignModule,
battleScenarios
);
verifyActivityCompletionSelectionSeparation(
dataModule,
actionModule,
explorationDataModule,
explorationActionModule,
campaignModule,
battleScenarios
);
verifyFormationCompatibility(dataModule);
verifyOldSaveAndPollutionSafety(
dataModule,
actionModule,
campaignModule,
battleScenarios
);
console.log(
'Third-camp preparation verification passed (activity/selection separation, explicit confirmation, formation compatibility, guarded overwrite, fourth-battle isolation, three-turn lifecycle, and old-save 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(
availability.every(({ selectable }) => selectable === false),
'Opening reward categories alone must not complete walkable-camp activities.'
);
const explored = literalCampaign(dataModule, {
completedActivities: ['information', 'equipment']
});
availability =
dataModule.resolveThirdCampPreparationAvailability(explored);
assert.equal(availability[0].selectable, true);
assert.deepEqual(availability[0].evidence, {
kind: 'exploration-activity',
battleId: dataModule.thirdCampPreparationSourceBattleId,
visitId: 'third-guangzong-sortie-camp',
activityId: 'information'
});
assert.equal(availability[1].selectable, true);
assert.deepEqual(availability[1].evidence, {
kind: 'exploration-activity',
battleId: dataModule.thirdCampPreparationSourceBattleId,
visitId: 'third-guangzong-sortie-camp',
activityId: '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, {
completedActivities: ['companion'],
completePriorityDialogue: true
});
availability =
dataModule.resolveThirdCampPreparationAvailability(companion);
const companionAvailability = availability.find(
({ priority }) => priority.id === 'companion'
);
assert.equal(companionAvailability?.selectable, true);
assert.equal(
companionAvailability?.evidence?.kind,
'exploration-activity'
);
assert.equal(
companionAvailability?.evidence?.dialogueId,
'guan-zhang-after-guangzong-road'
);
assert.equal(
companionAvailability?.evidence?.bondId,
'guan-yu__zhang-fei'
);
const legacyCompanion = literalCampaign(dataModule, {
completePriorityDialogue: true
});
assert.equal(
dataModule.resolveThirdCampPreparationAvailability(
legacyCompanion
).find(({ priority }) => priority.id === 'companion')
?.evidence?.kind,
'priority-return-dialogue',
'An already completed result dialogue must remain valid for old saves.'
);
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.completedCampVisits = [];
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 && 'unitIds' in fresh.evidence) {
assert.notEqual(fresh.evidence.unitIds[0], 'mutated');
}
});
}
function verifyGuardedPersistentOverwrite(
dataModule,
actionModule,
explorationDataModule,
explorationActionModule,
campaignModule,
battleScenarios
) {
prepareThirdVictory(campaignModule, battleScenarios, dataModule);
assert.equal(
actionModule.setThirdCampPreparationPriority('forged').reason,
'invalid-priority'
);
assert.equal(
actionModule.setThirdCampPreparationPriority('information').reason,
'activity-required'
);
const entry = explorationActionModule.enterThirdCampExploration();
assert.equal(entry.ok, true);
const informationActivity =
explorationActionModule.recordThirdCampExplorationActivity(
'information'
);
assert.equal(informationActivity.ok, true);
assert.equal(
actionModule.clearThirdCampPreparationPriority().ok,
true
);
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',
confirmed: true
});
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.'
);
const equipmentActivity =
explorationActionModule.recordThirdCampExplorationActivity(
'equipment'
);
assert.equal(equipmentActivity.ok, true);
assert.equal(
actionModule.clearThirdCampPreparationPriority().ok,
true
);
result = actionModule.setThirdCampPreparationPriority('equipment');
assert.equal(result.ok, true);
assert.equal(result.changed, true);
assert.equal(result.memory.priorityId, 'equipment');
const explorationDefinition =
explorationDataModule.resolveThirdCampExplorationDefinition(
campaignModule.getCampaignState()
);
const companionChoiceId =
explorationDefinition?.companionDialogue.choices[0]?.id;
assert(companionChoiceId);
const companionActivity =
explorationActionModule.completeThirdCampCompanionActivity(
companionChoiceId
);
assert.equal(companionActivity.ok, true);
assert.equal(
actionModule.clearThirdCampPreparationPriority().ok,
true
);
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 verifyActivityCompletionSelectionSeparation(
dataModule,
actionModule,
explorationDataModule,
explorationActionModule,
campaignModule,
battleScenarios
) {
const permutations = [
['information', 'equipment', 'companion'],
['information', 'companion', 'equipment'],
['equipment', 'information', 'companion'],
['equipment', 'companion', 'information'],
['companion', 'information', 'equipment'],
['companion', 'equipment', 'information']
];
permutations.forEach((activityOrder) => {
prepareThirdVictory(campaignModule, battleScenarios, dataModule);
assert.equal(
explorationActionModule.enterThirdCampExploration().ok,
true
);
activityOrder.forEach((activityId) => {
const result =
activityId === 'companion'
? explorationActionModule.completeThirdCampCompanionActivity(
explorationDataModule.resolveThirdCampExplorationDefinition(
campaignModule.getCampaignState()
).companionDialogue.choices[0].id
)
: explorationActionModule.recordThirdCampExplorationActivity(
activityId
);
assert.equal(result.ok, true);
assert.equal(
campaignModule.getCampaignState()
.thirdCampPreparationSelection,
undefined,
`Completing ${activityId} in ${activityOrder.join(' > ')} must not select a sortie bonus.`
);
});
const progress = actionModule.thirdCampPreparationProgress();
assert.equal(progress.selectedPriorityId, null);
assert.equal(progress.confirmed, false);
assert.equal(progress.ready, false);
assert(
progress.availability.every(({ selectable }) => selectable),
'All three completed activities must become selectable candidates.'
);
});
prepareThirdVictory(campaignModule, battleScenarios, dataModule);
explorationActionModule.enterThirdCampExploration();
explorationActionModule.recordThirdCampExplorationActivity(
'information'
);
const selected =
actionModule.setThirdCampPreparationPriority('information');
assert.equal(selected.ok, true);
assert.equal(selected.campaign.thirdCampPreparationSelection.confirmed, true);
explorationActionModule.recordThirdCampExplorationActivity(
'equipment'
);
explorationActionModule.recordThirdCampExplorationActivity(
'equipment'
);
assert.deepEqual(
campaignModule.getCampaignState().thirdCampPreparationSelection,
selected.campaign.thirdCampPreparationSelection,
'New and repeated activities must preserve the explicitly confirmed bonus.'
);
const legacy = campaignModule.getCampaignState();
legacy.thirdCampPreparationSelection = {
...legacy.thirdCampPreparationSelection
};
delete legacy.thirdCampPreparationSelection.confirmed;
const migrated = campaignModule.setCampaignState(legacy);
const migratedProgress =
actionModule.thirdCampPreparationProgress(migrated);
assert.equal(migratedProgress.selectedPriorityId, 'information');
assert.equal(migratedProgress.confirmed, false);
assert.equal(migratedProgress.ready, false);
assert.equal(
dataModule.resolveThirdCampPreparationMemory({
campaign: migrated,
battleId: dataModule.thirdCampPreparationTargetBattleId
}),
undefined,
'A legacy automatic selection must not silently become an active battle effect.'
);
assert.equal(
dataModule.resolveThirdCampPreparationBattleResumeMemory({
campaign: migrated,
battleId: dataModule.thirdCampPreparationTargetBattleId,
savedPriorityId: 'information'
})?.priorityId,
'information',
'A legacy battle already saved with the effect active must preserve that effect when resumed.'
);
assert.equal(
dataModule.resolveThirdCampPreparationBattleResumeMemory({
campaign: migrated,
battleId: dataModule.thirdCampPreparationTargetBattleId,
savedPriorityId: 'equipment'
}),
undefined,
'A legacy battle save must not activate a different preparation than the campaign selected.'
);
const reconfirmed =
actionModule.setThirdCampPreparationPriority('information');
assert.equal(reconfirmed.ok, true);
assert.equal(reconfirmed.changed, true);
assert.equal(reconfirmed.campaign.thirdCampPreparationSelection.confirmed, true);
const forgedRetry = campaignModule.getCampaignState();
forgedRetry.step = 'fourth-battle';
forgedRetry.latestBattleId =
dataModule.thirdCampPreparationTargetBattleId;
campaignModule.setCampaignState(forgedRetry);
assert.deepEqual(
actionModule.setThirdCampPreparationPriority('equipment'),
{ ok: false, reason: 'invalid-campaign' },
'A forged target latestBattleId without a target defeat record must not unlock retry preparation writes.'
);
campaignModule.setCampaignState(reconfirmed.campaign);
recordTargetDefeat(
campaignModule,
battleScenarios,
dataModule
);
assert.deepEqual(
campaignModule.getCampaignState()
.thirdCampPreparationSelection,
{
sourceBattleId:
dataModule.thirdCampPreparationSourceBattleId,
targetBattleId:
dataModule.thirdCampPreparationTargetBattleId,
priorityId: 'information',
confirmed: true
},
'A target-battle defeat must preserve the confirmed preparation for an immediate retry.'
);
const legacyRetry = campaignModule.getCampaignState();
legacyRetry.thirdCampPreparationSelection.confirmed = false;
const migratedLegacyRetry =
campaignModule.setCampaignState(legacyRetry);
assert.equal(
dataModule.shouldReviewThirdCampPreparationBeforeRetry(
migratedLegacyRetry
),
true,
'A legacy unconfirmed retry must return through the sortie ledger before entering battle.'
);
const retryReplacement =
actionModule.setThirdCampPreparationPriority('equipment');
assert.equal(retryReplacement.ok, true);
assert.equal(retryReplacement.changed, true);
assert.equal(
retryReplacement.campaign.thirdCampPreparationSelection
.priorityId,
'equipment',
'A defeated fourth-battle retry must keep the preparation browser editable.'
);
assert.equal(
dataModule.shouldReviewThirdCampPreparationBeforeRetry(
retryReplacement.campaign
),
false,
'Explicit retry confirmation must clear the legacy review redirect.'
);
}
function verifyFormationCompatibility(dataModule) {
const companion = dataModule.resolveThirdCampPreparationMemory({
campaign: eligibleLiteralCampaign(dataModule, 'companion'),
battleId: dataModule.thirdCampPreparationTargetBattleId
});
assert(companion);
const compatible =
dataModule.evaluateThirdCampPreparationCompatibility(
companion,
['liu-bei', 'guan-yu', 'zhang-fei']
);
assert.equal(compatible.compatible, true);
assert.deepEqual(compatible.requiredUnitIds, [
'guan-yu',
'zhang-fei'
]);
assert.deepEqual(compatible.missingUnitIds, []);
const incompatible =
dataModule.evaluateThirdCampPreparationCompatibility(
companion,
['liu-bei', 'guan-yu']
);
assert.equal(incompatible.compatible, false);
assert.deepEqual(incompatible.missingUnitIds, ['zhang-fei']);
assert.equal(incompatible.disabledReason, 'required-units-missing');
const result =
dataModule.resolveThirdCampPreparationBattlePresentation({
memory: companion,
stage: 'result',
usageCount: 0,
compatibility: incompatible
});
assert.equal(result?.status, 'incompatible');
assert.match(result?.text ?? '', /편성되지 않아.*미적용/);
const expiredMismatch =
dataModule.resolveThirdCampPreparationBattlePresentation({
memory: companion,
stage: 'turn',
turnNumber: 4,
usageCount: 0,
compatibility: incompatible
});
assert.equal(
expiredMismatch?.status,
'expired',
'An incompatible preparation must still leave the HUD after its three-turn lifecycle ends.'
);
const information = dataModule.resolveThirdCampPreparationMemory({
campaign: eligibleLiteralCampaign(dataModule, 'information'),
battleId: dataModule.thirdCampPreparationTargetBattleId
});
assert.equal(
dataModule.evaluateThirdCampPreparationCompatibility(
information,
['liu-bei']
).compatible,
true,
'Information and equipment preparations must remain formation independent.'
);
}
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',
confirmed: false
});
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
);
const staleAfterTargetVictory = literalCampaign(dataModule, {
completedActivities: ['information'],
selectedPriorityId: 'information'
});
staleAfterTargetVictory.battleHistory[
dataModule.thirdCampPreparationTargetBattleId
] = {
...structuredClone(
staleAfterTargetVictory.battleHistory[
dataModule.thirdCampPreparationSourceBattleId
]
),
battleId: dataModule.thirdCampPreparationTargetBattleId,
outcome: 'victory',
completedAt: new Date().toISOString()
};
assert.equal(
campaignModule.setCampaignState(staleAfterTargetVictory)
.thirdCampPreparationSelection,
undefined,
'A legacy save that already won the target battle must discard its stale preparation selection.'
);
}
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 recordTargetDefeat(
campaignModule,
battleScenarios,
dataModule
) {
const scenario =
battleScenarios[dataModule.thirdCampPreparationTargetBattleId];
campaignModule.setFirstBattleReport({
battleId: scenario.id,
battleTitle: scenario.title,
outcome: 'defeat',
turnNumber: 7,
rewardGold: 0,
defeatedEnemies: 0,
totalEnemies: scenario.units.filter(
(unit) => unit.faction === 'enemy'
).length,
objectives: scenario.objectives.map((objective) => ({
id: objective.id,
label: objective.label,
achieved: false,
status: 'failed',
detail: '미달성',
rewardGold: 0
})),
units: scenario.units,
bonds: scenario.bonds.map((bond) => ({
...bond,
battleExp: 0
})),
itemRewards: [],
campaignRewards: {
supplies: [],
equipment: [],
reputation: [],
recruits: [],
unlocks: [],
note: ''
},
completedCampDialogues: [],
completedCampVisits: [],
createdAt: '2026-07-27T01:00:00.000Z'
});
const campaign = campaignModule.getCampaignState();
assert.equal(campaign.step, 'fourth-battle');
assert.equal(
campaign.latestBattleId,
dataModule.thirdCampPreparationTargetBattleId
);
assert.equal(
campaign.thirdCampPreparationSelection?.confirmed,
true,
'A defeat must retain the confirmed preparation for retry.'
);
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, {
completedActivities: [priorityId],
completePriorityDialogue: priorityId === 'companion',
selectedPriorityId: priorityId
});
}
function literalCampaign(
dataModule,
{
acknowledgedCategories = [],
completedActivities = [],
completePriorityDialogue = false,
selectedPriorityId,
selectionConfirmed = true,
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: completedActivities.map(
(activityId) =>
`third-guangzong-sortie-camp:activity:${activityId}`
),
campVisitChoiceIds: {},
...(selectedPriorityId
? {
thirdCampPreparationSelection: {
sourceBattleId:
dataModule.thirdCampPreparationSourceBattleId,
targetBattleId:
dataModule.thirdCampPreparationTargetBattleId,
priorityId: selectedPriorityId,
confirmed: selectionConfirmed
}
}
: {}),
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
) ?? []
};
}