feat: make third-camp preparation choices explicit

This commit is contained in:
2026-07-28 01:29:20 +09:00
parent 1a81ce5689
commit a41672bd0e
11 changed files with 1608 additions and 143 deletions

View File

@@ -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
}
}
: {}),