feat: preserve camp exploration resume context
This commit is contained in:
584
scripts/verify-camp-visit-resume-state.mjs
Normal file
584
scripts/verify-camp-visit-resume-state.mjs
Normal file
@@ -0,0 +1,584 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const storage = new Map();
|
||||
globalThis.window = {
|
||||
localStorage: {
|
||||
getItem(key) {
|
||||
return storage.has(key) ? storage.get(key) : null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
storage.set(key, String(value));
|
||||
},
|
||||
removeItem(key) {
|
||||
storage.delete(key);
|
||||
},
|
||||
clear() {
|
||||
storage.clear();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const server = await createServer({
|
||||
logLevel: 'error',
|
||||
server: { middlewareMode: true },
|
||||
appType: 'custom'
|
||||
});
|
||||
|
||||
try {
|
||||
const campaign = await server.ssrLoadModule(
|
||||
'/src/game/state/campaignState.ts'
|
||||
);
|
||||
const { battleScenarios } = await server.ssrLoadModule(
|
||||
'/src/game/data/battles.ts'
|
||||
);
|
||||
const firstPursuit = await server.ssrLoadModule(
|
||||
'/src/game/data/firstPursuitScoutMemory.ts'
|
||||
);
|
||||
const secondRelief = await server.ssrLoadModule(
|
||||
'/src/game/data/secondBattleReliefExploration.ts'
|
||||
);
|
||||
const thirdCamp = await server.ssrLoadModule(
|
||||
'/src/game/data/thirdCampExploration.ts'
|
||||
);
|
||||
|
||||
const fixtures = [
|
||||
{
|
||||
visitId: firstPursuit.firstPursuitScoutVisitId,
|
||||
sourceBattleId: firstPursuit.firstPursuitScoutSourceBattleId,
|
||||
step: 'first-camp',
|
||||
progress: {
|
||||
title: '탁현 의용군 정찰 막사',
|
||||
meta: '정찰 탐색 이어하기'
|
||||
}
|
||||
},
|
||||
{
|
||||
visitId: secondRelief.secondBattleReliefVisitId,
|
||||
sourceBattleId: secondRelief.secondBattleReliefSourceBattleId,
|
||||
step: 'second-camp',
|
||||
progress: {
|
||||
title: '북쪽 마을·나루 구호',
|
||||
meta: '현장 탐색 이어하기'
|
||||
}
|
||||
},
|
||||
{
|
||||
visitId: thirdCamp.thirdCampExplorationVisitId,
|
||||
sourceBattleId: thirdCamp.thirdCampExplorationSourceBattleId,
|
||||
step: 'third-camp',
|
||||
progress: {
|
||||
title: '광종 연합군 주둔지',
|
||||
meta: '출진 준비 이어하기'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
[...campaign.campaignCampVisitIds],
|
||||
fixtures.map(({ visitId }) => visitId),
|
||||
'The exported camp-visit allowlist must contain the three resumable visits in campaign order.'
|
||||
);
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
verifyValidSetterPersistence(fixture);
|
||||
verifyNormalizationFailures(fixture);
|
||||
verifyCampaignStepClearsVisit(fixture);
|
||||
verifyBattleRecordUpdateClearsVisit(fixture, 'victory');
|
||||
verifyBattleRecordUpdateClearsVisit(fixture, 'defeat');
|
||||
}
|
||||
|
||||
verifyCityMutualExclusion();
|
||||
verifyLegacySaveWithoutCampVisitField();
|
||||
|
||||
console.log(
|
||||
'Verified camp-visit resume state for all three visits: setter validation, base/active-slot persistence, reload retention, resume summaries, invalid-state normalization, campaign-step and battle-record clearing, city mutual exclusion, and legacy-save compatibility.'
|
||||
);
|
||||
|
||||
function verifyValidSetterPersistence(fixture) {
|
||||
storage.clear();
|
||||
campaign.setCampaignState(validCampVisitState(fixture, 2));
|
||||
|
||||
const activated = campaign.setActiveCampVisitId(fixture.visitId);
|
||||
assert.equal(
|
||||
activated.activeCampVisitId,
|
||||
fixture.visitId,
|
||||
`${fixture.visitId}: the valid setter call must activate the visit.`
|
||||
);
|
||||
assert.equal(
|
||||
activated.activeCityStayId,
|
||||
undefined,
|
||||
`${fixture.visitId}: activating a camp visit must leave no active city stay.`
|
||||
);
|
||||
|
||||
assertPersistedVisit(
|
||||
fixture.visitId,
|
||||
2,
|
||||
`${fixture.visitId}: setter persistence`
|
||||
);
|
||||
|
||||
const loadedFromBase = campaign.loadCampaignState();
|
||||
assert.equal(
|
||||
loadedFromBase.activeCampVisitId,
|
||||
fixture.visitId,
|
||||
`${fixture.visitId}: base-save reload must retain the active visit.`
|
||||
);
|
||||
const loadedFromSlot = campaign.loadCampaignState(2);
|
||||
assert.equal(
|
||||
loadedFromSlot.activeCampVisitId,
|
||||
fixture.visitId,
|
||||
`${fixture.visitId}: active-slot reload must retain the active visit.`
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
campaign.summarizeCampaignProgress(loadedFromSlot),
|
||||
fixture.progress,
|
||||
`${fixture.visitId}: campaign progress must describe the resumed exploration location.`
|
||||
);
|
||||
const slotSummary = campaign
|
||||
.listCampaignSaveSlots()
|
||||
.find(({ slot }) => slot === 2);
|
||||
assert.deepEqual(
|
||||
{
|
||||
title: slotSummary?.progressTitle,
|
||||
meta: slotSummary?.progressMeta
|
||||
},
|
||||
fixture.progress,
|
||||
`${fixture.visitId}: the active save-slot summary must expose the same resume location and meta.`
|
||||
);
|
||||
|
||||
const cleared = campaign.setActiveCampVisitId();
|
||||
assert.equal(
|
||||
cleared.activeCampVisitId,
|
||||
undefined,
|
||||
`${fixture.visitId}: an empty setter call must clear the active visit.`
|
||||
);
|
||||
assertPersistedVisit(
|
||||
undefined,
|
||||
2,
|
||||
`${fixture.visitId}: explicit clear persistence`
|
||||
);
|
||||
|
||||
campaign.setCampaignState(validCampVisitState(fixture, 2));
|
||||
campaign.setActiveCampVisitId(fixture.visitId);
|
||||
const invalidSetterResult = campaign.setActiveCampVisitId(
|
||||
'unknown-camp-visit'
|
||||
);
|
||||
assert.equal(
|
||||
invalidSetterResult.activeCampVisitId,
|
||||
undefined,
|
||||
`${fixture.visitId}: an unknown setter value must clear rather than retain a stale visit.`
|
||||
);
|
||||
assertPersistedVisit(
|
||||
undefined,
|
||||
2,
|
||||
`${fixture.visitId}: invalid setter persistence`
|
||||
);
|
||||
}
|
||||
|
||||
function verifyNormalizationFailures(fixture) {
|
||||
const invalidId = validCampVisitState(fixture);
|
||||
invalidId.activeCampVisitId = 'unknown-camp-visit';
|
||||
assertNormalizedAway(
|
||||
invalidId,
|
||||
`${fixture.visitId}: unknown visit id`
|
||||
);
|
||||
|
||||
const wrongStep = validCampVisitState(fixture);
|
||||
wrongStep.step = 'fourth-camp';
|
||||
assertNormalizedAway(
|
||||
wrongStep,
|
||||
`${fixture.visitId}: mismatched campaign step`
|
||||
);
|
||||
|
||||
const wrongLatestBattle = validCampVisitState(fixture);
|
||||
const alternateBattleId = alternateBattleFor(fixture.sourceBattleId);
|
||||
const alternateReport = battleReport(alternateBattleId, 'victory');
|
||||
wrongLatestBattle.battleHistory[alternateBattleId] =
|
||||
battleSettlement(alternateReport);
|
||||
wrongLatestBattle.latestBattleId = alternateBattleId;
|
||||
assertNormalizedAway(
|
||||
wrongLatestBattle,
|
||||
`${fixture.visitId}: mismatched latest battle`
|
||||
);
|
||||
|
||||
const defeatedSettlement = validCampVisitState(fixture);
|
||||
defeatedSettlement.battleHistory[fixture.sourceBattleId].outcome =
|
||||
'defeat';
|
||||
assertNormalizedAway(
|
||||
defeatedSettlement,
|
||||
`${fixture.visitId}: defeated source settlement`
|
||||
);
|
||||
|
||||
const missingReport = validCampVisitState(fixture);
|
||||
delete missingReport.firstBattleReport;
|
||||
assertNormalizedAway(
|
||||
missingReport,
|
||||
`${fixture.visitId}: missing firstBattleReport`
|
||||
);
|
||||
|
||||
const defeatedReport = validCampVisitState(fixture);
|
||||
defeatedReport.firstBattleReport.outcome = 'defeat';
|
||||
assertNormalizedAway(
|
||||
defeatedReport,
|
||||
`${fixture.visitId}: defeated firstBattleReport`
|
||||
);
|
||||
|
||||
const wrongReportBattle = validCampVisitState(fixture);
|
||||
wrongReportBattle.firstBattleReport = battleReport(
|
||||
alternateBattleFor(fixture.sourceBattleId),
|
||||
'victory'
|
||||
);
|
||||
assertNormalizedAway(
|
||||
wrongReportBattle,
|
||||
`${fixture.visitId}: mismatched firstBattleReport battle`
|
||||
);
|
||||
|
||||
const pendingAftermath = validCampVisitState(fixture);
|
||||
pendingAftermath.pendingAftermathBattleId =
|
||||
fixture.sourceBattleId;
|
||||
assertNormalizedAway(
|
||||
pendingAftermath,
|
||||
`${fixture.visitId}: pending victory aftermath`
|
||||
);
|
||||
|
||||
storage.clear();
|
||||
const setterDuringAftermath = validCampVisitState(fixture);
|
||||
setterDuringAftermath.pendingAftermathBattleId =
|
||||
fixture.sourceBattleId;
|
||||
campaign.setCampaignState(setterDuringAftermath);
|
||||
assert.equal(
|
||||
campaign.setActiveCampVisitId(fixture.visitId)
|
||||
.activeCampVisitId,
|
||||
undefined,
|
||||
`${fixture.visitId}: a pending aftermath must reject exploration activation.`
|
||||
);
|
||||
}
|
||||
|
||||
function verifyCampaignStepClearsVisit(fixture) {
|
||||
storage.clear();
|
||||
campaign.setCampaignState(validCampVisitState(fixture, 2));
|
||||
campaign.setActiveCampVisitId(fixture.visitId);
|
||||
|
||||
const changed = campaign.markCampaignStep('fourth-camp');
|
||||
assert.equal(
|
||||
changed.activeCampVisitId,
|
||||
undefined,
|
||||
`${fixture.visitId}: changing to an incompatible campaign step must clear the active visit.`
|
||||
);
|
||||
assertPersistedVisit(
|
||||
undefined,
|
||||
2,
|
||||
`${fixture.visitId}: markCampaignStep persistence`
|
||||
);
|
||||
}
|
||||
|
||||
function verifyBattleRecordUpdateClearsVisit(fixture, outcome) {
|
||||
storage.clear();
|
||||
campaign.setCampaignState(validCampVisitState(fixture, 2));
|
||||
campaign.setActiveCampVisitId(fixture.visitId);
|
||||
|
||||
campaign.setFirstBattleReport(
|
||||
battleReport(fixture.sourceBattleId, outcome)
|
||||
);
|
||||
assert.equal(
|
||||
campaign.getCampaignState().activeCampVisitId,
|
||||
undefined,
|
||||
`${fixture.visitId}: a ${outcome} battle-record update must clear the active visit.`
|
||||
);
|
||||
assertPersistedVisit(
|
||||
undefined,
|
||||
2,
|
||||
`${fixture.visitId}: ${outcome} battle-record persistence`
|
||||
);
|
||||
}
|
||||
|
||||
function verifyCityMutualExclusion() {
|
||||
const firstFixture = fixtures[0];
|
||||
const campState = validCampVisitState(firstFixture, 2);
|
||||
campState.activeCampVisitId = firstFixture.visitId;
|
||||
campState.activeCityStayId = 'xuzhou';
|
||||
const normalizedCamp = loadRawState(campState);
|
||||
assert.equal(
|
||||
normalizedCamp.activeCampVisitId,
|
||||
firstFixture.visitId,
|
||||
'A valid camp visit must survive when a stale city marker is present.'
|
||||
);
|
||||
assert.equal(
|
||||
normalizedCamp.activeCityStayId,
|
||||
undefined,
|
||||
'A stale city marker must normalize away in a valid camp-visit context.'
|
||||
);
|
||||
|
||||
const cityState = validCityState(3);
|
||||
cityState.activeCityStayId = 'xuzhou';
|
||||
cityState.activeCampVisitId = firstFixture.visitId;
|
||||
const normalizedCity = loadRawState(cityState);
|
||||
assert.equal(
|
||||
normalizedCity.activeCityStayId,
|
||||
'xuzhou',
|
||||
'A valid city stay must survive when a stale camp-visit marker is present.'
|
||||
);
|
||||
assert.equal(
|
||||
normalizedCity.activeCampVisitId,
|
||||
undefined,
|
||||
'A stale camp-visit marker must normalize away in a valid city context.'
|
||||
);
|
||||
campaign.saveCampaignState(normalizedCity, 3);
|
||||
assert.equal(
|
||||
storedState(campaign.campaignStorageKey).activeCampVisitId,
|
||||
undefined,
|
||||
'Persisting a normalized city state must remove the stale camp marker from the base save.'
|
||||
);
|
||||
assert.equal(
|
||||
storedState(slotKey(3)).activeCampVisitId,
|
||||
undefined,
|
||||
'Persisting a normalized city state must remove the stale camp marker from the active slot.'
|
||||
);
|
||||
|
||||
campaign.setCampaignState(validCampVisitState(firstFixture, 2));
|
||||
campaign.setActiveCampVisitId(firstFixture.visitId);
|
||||
const invalidCitySetter = campaign.setActiveCityStayId('xuzhou');
|
||||
assert.equal(
|
||||
invalidCitySetter.activeCampVisitId,
|
||||
firstFixture.visitId,
|
||||
'An invalid city setter call must not erase a valid camp-visit resume marker.'
|
||||
);
|
||||
|
||||
campaign.setCampaignState(validCityState(3));
|
||||
campaign.setActiveCityStayId('xuzhou');
|
||||
const invalidCampSetter = campaign.setActiveCampVisitId(
|
||||
firstFixture.visitId
|
||||
);
|
||||
assert.equal(
|
||||
invalidCampSetter.activeCityStayId,
|
||||
'xuzhou',
|
||||
'An invalid camp setter call must not erase a valid city-stay marker.'
|
||||
);
|
||||
assert.equal(
|
||||
invalidCampSetter.activeCampVisitId,
|
||||
undefined,
|
||||
'An invalid camp setter call in a city context must not create a camp marker.'
|
||||
);
|
||||
}
|
||||
|
||||
function verifyLegacySaveWithoutCampVisitField() {
|
||||
const fixture = fixtures[0];
|
||||
const legacy = validCampVisitState(fixture, 1);
|
||||
delete legacy.activeCampVisitId;
|
||||
delete legacy.activeCityStayId;
|
||||
|
||||
const loaded = loadRawState(legacy);
|
||||
assert.equal(
|
||||
loaded.activeCampVisitId,
|
||||
undefined,
|
||||
'A legacy campaign save without activeCampVisitId must load safely.'
|
||||
);
|
||||
assert.deepEqual(
|
||||
campaign.summarizeCampaignProgress(loaded),
|
||||
{
|
||||
title: battleScenarios[fixture.sourceBattleId].title,
|
||||
meta: '승리 후 군영'
|
||||
},
|
||||
'A legacy save without the new field must keep the established camp summary.'
|
||||
);
|
||||
|
||||
campaign.saveCampaignState(loaded, 1);
|
||||
assert.equal(
|
||||
Object.hasOwn(storedState(campaign.campaignStorageKey), 'activeCampVisitId'),
|
||||
false,
|
||||
'Saving a legacy state must not synthesize an inactive camp-visit field in the base save.'
|
||||
);
|
||||
assert.equal(
|
||||
Object.hasOwn(storedState(slotKey(1)), 'activeCampVisitId'),
|
||||
false,
|
||||
'Saving a legacy state must not synthesize an inactive camp-visit field in the active slot.'
|
||||
);
|
||||
|
||||
const minimalLegacy = {
|
||||
updatedAt: '2026-07-01T00:00:00.000Z',
|
||||
step: 'first-camp',
|
||||
activeSaveSlot: 1,
|
||||
gold: 73
|
||||
};
|
||||
const minimalLoaded = loadRawState(minimalLegacy);
|
||||
assert.equal(minimalLoaded.version, 1);
|
||||
assert.equal(minimalLoaded.step, 'first-camp');
|
||||
assert.equal(minimalLoaded.gold, 73);
|
||||
assert.equal(minimalLoaded.activeCampVisitId, undefined);
|
||||
}
|
||||
|
||||
function assertNormalizedAway(state, label) {
|
||||
const slot = state.activeSaveSlot ?? 1;
|
||||
const loadedFromBase = loadRawState(state);
|
||||
assert.equal(
|
||||
loadedFromBase.activeCampVisitId,
|
||||
undefined,
|
||||
`${label}: base-save normalization must remove the active visit.`
|
||||
);
|
||||
|
||||
storage.clear();
|
||||
const serialized = JSON.stringify(state);
|
||||
storage.set(campaign.campaignStorageKey, serialized);
|
||||
storage.set(slotKey(slot), serialized);
|
||||
const loadedFromSlot = campaign.loadCampaignState(slot);
|
||||
assert.equal(
|
||||
loadedFromSlot.activeCampVisitId,
|
||||
undefined,
|
||||
`${label}: slot-save normalization must remove the active visit.`
|
||||
);
|
||||
|
||||
campaign.saveCampaignState(loadedFromSlot, slot);
|
||||
assertPersistedVisit(undefined, slot, `${label}: normalized persistence`);
|
||||
}
|
||||
|
||||
function assertPersistedVisit(expectedVisitId, slot, label) {
|
||||
const base = storedState(campaign.campaignStorageKey);
|
||||
const slotted = storedState(slotKey(slot));
|
||||
assert(base, `${label}: base campaign save is missing.`);
|
||||
assert(slotted, `${label}: active slot save is missing.`);
|
||||
assert.equal(
|
||||
base.activeCampVisitId,
|
||||
expectedVisitId,
|
||||
`${label}: unexpected base-save active visit.`
|
||||
);
|
||||
assert.equal(
|
||||
slotted.activeCampVisitId,
|
||||
expectedVisitId,
|
||||
`${label}: unexpected slot-save active visit.`
|
||||
);
|
||||
assert.equal(
|
||||
base.activeSaveSlot,
|
||||
slot,
|
||||
`${label}: base save must retain the active slot.`
|
||||
);
|
||||
assert.equal(
|
||||
slotted.activeSaveSlot,
|
||||
slot,
|
||||
`${label}: slot save must retain the active slot.`
|
||||
);
|
||||
}
|
||||
|
||||
function loadRawState(state) {
|
||||
storage.clear();
|
||||
const serialized = JSON.stringify(state);
|
||||
storage.set(campaign.campaignStorageKey, serialized);
|
||||
storage.set(slotKey(state.activeSaveSlot ?? 1), serialized);
|
||||
return campaign.loadCampaignState();
|
||||
}
|
||||
|
||||
function validCampVisitState(fixture, slot = 1) {
|
||||
const report = battleReport(fixture.sourceBattleId, 'victory');
|
||||
const state = campaign.createInitialCampaignState();
|
||||
state.updatedAt = '2026-07-28T08:00:00.000Z';
|
||||
state.step = fixture.step;
|
||||
state.activeSaveSlot = slot;
|
||||
state.gold = 420;
|
||||
state.roster = clone(report.units);
|
||||
state.bonds = clone(report.bonds);
|
||||
state.latestBattleId = fixture.sourceBattleId;
|
||||
state.firstBattleReport = report;
|
||||
state.battleHistory = {
|
||||
[fixture.sourceBattleId]: battleSettlement(report)
|
||||
};
|
||||
state.activeCampVisitId = fixture.visitId;
|
||||
delete state.pendingAftermathBattleId;
|
||||
delete state.activeCityStayId;
|
||||
return state;
|
||||
}
|
||||
|
||||
function validCityState(slot = 1) {
|
||||
const battleId = 'seventh-battle-xuzhou-rescue';
|
||||
const report = battleReport(battleId, 'victory');
|
||||
const state = campaign.createInitialCampaignState();
|
||||
state.updatedAt = '2026-07-28T08:20:00.000Z';
|
||||
state.step = 'seventh-camp';
|
||||
state.activeSaveSlot = slot;
|
||||
state.gold = 640;
|
||||
state.roster = clone(report.units);
|
||||
state.bonds = clone(report.bonds);
|
||||
state.latestBattleId = battleId;
|
||||
state.firstBattleReport = report;
|
||||
state.battleHistory = {
|
||||
[battleId]: battleSettlement(report)
|
||||
};
|
||||
delete state.pendingAftermathBattleId;
|
||||
return state;
|
||||
}
|
||||
|
||||
function battleReport(battleId, outcome) {
|
||||
const scenario = battleScenarios[battleId];
|
||||
assert(scenario, `Missing battle scenario fixture ${battleId}.`);
|
||||
const alliedUnits = scenario.units
|
||||
.filter((unit) => unit.faction === 'ally')
|
||||
.map((unit) => clone(unit));
|
||||
return {
|
||||
battleId,
|
||||
battleTitle: scenario.title,
|
||||
outcome,
|
||||
turnNumber: 5,
|
||||
rewardGold: outcome === 'victory' ? 100 : 0,
|
||||
defeatedEnemies:
|
||||
outcome === 'victory'
|
||||
? scenario.units.filter((unit) => unit.faction === 'enemy').length
|
||||
: 0,
|
||||
totalEnemies: scenario.units.filter(
|
||||
(unit) => unit.faction === 'enemy'
|
||||
).length,
|
||||
objectives: [],
|
||||
units: alliedUnits,
|
||||
bonds: scenario.bonds.map((bond) => ({
|
||||
...clone(bond),
|
||||
battleExp: 0
|
||||
})),
|
||||
itemRewards: [],
|
||||
campaignRewards: {
|
||||
supplies: [],
|
||||
equipment: [],
|
||||
reputation: [],
|
||||
recruits: [],
|
||||
unlocks: []
|
||||
},
|
||||
completedCampDialogues: [],
|
||||
completedCampVisits: [],
|
||||
createdAt: '2026-07-28T08:00:00.000Z'
|
||||
};
|
||||
}
|
||||
|
||||
function battleSettlement(report) {
|
||||
return {
|
||||
battleId: report.battleId,
|
||||
battleTitle: report.battleTitle,
|
||||
outcome: report.outcome,
|
||||
turnNumber: report.turnNumber,
|
||||
rewardGold: report.rewardGold,
|
||||
itemRewards: [...report.itemRewards],
|
||||
campaignRewards: clone(report.campaignRewards),
|
||||
objectives: [],
|
||||
units: [],
|
||||
bonds: [],
|
||||
completedAt: report.createdAt
|
||||
};
|
||||
}
|
||||
|
||||
function alternateBattleFor(sourceBattleId) {
|
||||
return Object.keys(battleScenarios).find(
|
||||
(battleId) =>
|
||||
battleId !== sourceBattleId &&
|
||||
!fixtures.some((fixture) => fixture.sourceBattleId === battleId)
|
||||
);
|
||||
}
|
||||
|
||||
function storedState(key) {
|
||||
const raw = storage.get(key);
|
||||
return raw ? JSON.parse(raw) : undefined;
|
||||
}
|
||||
|
||||
function slotKey(slot) {
|
||||
return `${campaign.campaignStorageKey}:slot-${slot}`;
|
||||
}
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
Reference in New Issue
Block a user