feat: make third-camp preparation choices explicit
This commit is contained in:
@@ -61,6 +61,15 @@ try {
|
||||
campaignModule,
|
||||
battleScenarios
|
||||
);
|
||||
verifyActivityCompletionSelectionSeparation(
|
||||
dataModule,
|
||||
actionModule,
|
||||
explorationDataModule,
|
||||
explorationActionModule,
|
||||
campaignModule,
|
||||
battleScenarios
|
||||
);
|
||||
verifyFormationCompatibility(dataModule);
|
||||
verifyOldSaveAndPollutionSafety(
|
||||
dataModule,
|
||||
actionModule,
|
||||
@@ -69,7 +78,7 @@ try {
|
||||
);
|
||||
|
||||
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).'
|
||||
'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();
|
||||
@@ -486,7 +495,8 @@ function verifyGuardedPersistentOverwrite(
|
||||
assert.deepEqual(result.campaign.thirdCampPreparationSelection, {
|
||||
sourceBattleId: dataModule.thirdCampPreparationSourceBattleId,
|
||||
targetBattleId: dataModule.thirdCampPreparationTargetBattleId,
|
||||
priorityId: 'information'
|
||||
priorityId: 'information',
|
||||
confirmed: true
|
||||
});
|
||||
assert.equal(
|
||||
result.campaign.completedCampVisits.filter(
|
||||
@@ -618,6 +628,244 @@ function verifyGuardedPersistentOverwrite(
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -744,7 +992,8 @@ function verifyOldSaveAndPollutionSafety(
|
||||
assert.deepEqual(state.thirdCampPreparationSelection, {
|
||||
sourceBattleId: dataModule.thirdCampPreparationSourceBattleId,
|
||||
targetBattleId: dataModule.thirdCampPreparationTargetBattleId,
|
||||
priorityId: 'information'
|
||||
priorityId: 'information',
|
||||
confirmed: false
|
||||
});
|
||||
validCandidate.thirdCampPreparationSelection.priorityId =
|
||||
'equipment';
|
||||
@@ -818,6 +1067,29 @@ function verifyOldSaveAndPollutionSafety(
|
||||
}),
|
||||
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(
|
||||
@@ -883,6 +1155,63 @@ function prepareThirdVictory(
|
||||
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,
|
||||
@@ -954,6 +1283,7 @@ function literalCampaign(
|
||||
completedActivities = [],
|
||||
completePriorityDialogue = false,
|
||||
selectedPriorityId,
|
||||
selectionConfirmed = true,
|
||||
legacyReportOnly = false
|
||||
} = {}
|
||||
) {
|
||||
@@ -992,7 +1322,8 @@ function literalCampaign(
|
||||
dataModule.thirdCampPreparationSourceBattleId,
|
||||
targetBattleId:
|
||||
dataModule.thirdCampPreparationTargetBattleId,
|
||||
priorityId: selectedPriorityId
|
||||
priorityId: selectedPriorityId,
|
||||
confirmed: selectionConfirmed
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
|
||||
Reference in New Issue
Block a user