1021 lines
30 KiB
JavaScript
1021 lines
30 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
|
|
);
|
|
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(
|
|
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'
|
|
});
|
|
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 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, {
|
|
completedActivities: [priorityId],
|
|
completePriorityDialogue: priorityId === 'companion',
|
|
selectedPriorityId: priorityId
|
|
});
|
|
}
|
|
|
|
function literalCampaign(
|
|
dataModule,
|
|
{
|
|
acknowledgedCategories = [],
|
|
completedActivities = [],
|
|
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: completedActivities.map(
|
|
(activityId) =>
|
|
`third-guangzong-sortie-camp:activity:${activityId}`
|
|
),
|
|
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
|
|
) ?? []
|
|
};
|
|
}
|