501 lines
15 KiB
JavaScript
501 lines
15 KiB
JavaScript
import { readFile } from 'node:fs/promises';
|
|
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 memoryModule = await server.ssrLoadModule(
|
|
'/src/game/data/firstPursuitScoutMemory.ts'
|
|
);
|
|
const visitModule = await server.ssrLoadModule(
|
|
'/src/game/data/firstPursuitScoutVisit.ts'
|
|
);
|
|
const campaignModule = await server.ssrLoadModule(
|
|
'/src/game/state/campaignState.ts'
|
|
);
|
|
const actionModule = await server.ssrLoadModule(
|
|
'/src/game/state/firstPursuitScoutActions.ts'
|
|
);
|
|
const { battleScenarios } = await server.ssrLoadModule(
|
|
'/src/game/data/battles.ts'
|
|
);
|
|
|
|
verifyCanonicalIds(memoryModule);
|
|
verifyCanonicalVisitDefinition(memoryModule, visitModule);
|
|
verifyValidChoices(memoryModule, campaignModule, battleScenarios);
|
|
verifyInvalidMarkers(memoryModule, campaignModule, battleScenarios);
|
|
verifyTargetIsolation(memoryModule, campaignModule, battleScenarios);
|
|
verifyGuardedExplorationCompletion(
|
|
memoryModule,
|
|
visitModule,
|
|
campaignModule,
|
|
actionModule,
|
|
battleScenarios
|
|
);
|
|
await verifyCampExplorationWiring(memoryModule, visitModule);
|
|
|
|
console.log(
|
|
'First-pursuit scout verification passed (canonical visit data, guarded exploration completion, strict memory validation, target isolation, and camp-scene wiring).'
|
|
);
|
|
} finally {
|
|
await server.close();
|
|
}
|
|
|
|
function verifyCanonicalIds(memoryModule) {
|
|
assert(
|
|
memoryModule.firstPursuitScoutVisitId === 'first-pursuit-scout-tent',
|
|
'The first-pursuit scout visit id must remain stable.'
|
|
);
|
|
assert(
|
|
memoryModule.firstPursuitScoutSourceBattleId ===
|
|
'first-battle-zhuo-commandery' &&
|
|
memoryModule.firstPursuitScoutTargetBattleId ===
|
|
'second-battle-yellow-turban-pursuit',
|
|
'The scout memory must remain scoped from the first victory to the second battle.'
|
|
);
|
|
assert(
|
|
JSON.stringify(memoryModule.firstPursuitScoutChoiceIds) ===
|
|
JSON.stringify(['trace-river-ambush', 'mark-village-relief']),
|
|
'The two canonical scout choice ids must remain stable and ordered.'
|
|
);
|
|
}
|
|
|
|
function verifyCanonicalVisitDefinition(memoryModule, visitModule) {
|
|
const definition = visitModule.firstPursuitScoutVisitDefinition;
|
|
assert(
|
|
definition.id === memoryModule.firstPursuitScoutVisitId &&
|
|
definition.availableAfterBattleIds.length === 1 &&
|
|
definition.availableAfterBattleIds[0] ===
|
|
memoryModule.firstPursuitScoutSourceBattleId,
|
|
'The first-camp exploration definition must use the canonical visit and source-battle ids.'
|
|
);
|
|
assert(
|
|
JSON.stringify(definition.choices.map(({ id }) => id)) ===
|
|
JSON.stringify(memoryModule.firstPursuitScoutChoiceIds),
|
|
'The first-camp exploration definition must expose the two canonical choices in order.'
|
|
);
|
|
definition.choices.forEach((choice) => {
|
|
assert(
|
|
choice.label.length > 0 &&
|
|
choice.response.startsWith('간옹:') &&
|
|
choice.bondExp > 0 &&
|
|
choice.itemRewards.length > 0,
|
|
`Choice ${choice.id} must include dialogue, bond, and item feedback.`
|
|
);
|
|
assert(
|
|
visitModule.getFirstPursuitScoutVisitChoice(choice.id) === choice,
|
|
`Choice lookup must return the canonical object for ${choice.id}.`
|
|
);
|
|
});
|
|
}
|
|
|
|
function verifyValidChoices(memoryModule, campaignModule, battleScenarios) {
|
|
const memories = memoryModule.firstPursuitScoutChoiceIds.map((choiceId) => {
|
|
const campaign = createCompletedScoutCampaign(
|
|
memoryModule,
|
|
campaignModule,
|
|
battleScenarios,
|
|
choiceId
|
|
);
|
|
const memory = memoryModule.resolveFirstPursuitScoutMemory({
|
|
campaign,
|
|
battleId: memoryModule.firstPursuitScoutTargetBattleId
|
|
});
|
|
assert(
|
|
memory?.visitId === memoryModule.firstPursuitScoutVisitId &&
|
|
memory.choiceId === choiceId &&
|
|
memory.choiceLabel.length > 0 &&
|
|
memory.deploymentIntentPreview === true &&
|
|
memory.campSummaryLine.startsWith('군영 준비 · ') &&
|
|
memory.openingLines.length === 2 &&
|
|
memory.openingLines[0] === memory.campSummaryLine,
|
|
`A completed canonical choice must resolve a concise target-battle payoff: ${JSON.stringify(memory)}`
|
|
);
|
|
return memory;
|
|
});
|
|
|
|
assert(
|
|
memories[0].campSummaryLine !== memories[1].campSummaryLine &&
|
|
memories[0].openingLines[1] !== memories[1].openingLines[1],
|
|
'Each scout choice must return distinct camp and opening guidance.'
|
|
);
|
|
|
|
memories[0].openingLines[0] = 'mutated outside resolver';
|
|
const campaign = createCompletedScoutCampaign(
|
|
memoryModule,
|
|
campaignModule,
|
|
battleScenarios,
|
|
memoryModule.firstPursuitScoutChoiceIds[0]
|
|
);
|
|
const resolvedAgain = memoryModule.resolveFirstPursuitScoutMemory({
|
|
campaign,
|
|
battleId: memoryModule.firstPursuitScoutTargetBattleId
|
|
});
|
|
assert(
|
|
resolvedAgain?.openingLines[0] === resolvedAgain?.campSummaryLine,
|
|
'Resolved opening lines must be cloned instead of exposing canonical data.'
|
|
);
|
|
}
|
|
|
|
function verifyInvalidMarkers(
|
|
memoryModule,
|
|
campaignModule,
|
|
battleScenarios
|
|
) {
|
|
const valid = createCompletedScoutCampaign(
|
|
memoryModule,
|
|
campaignModule,
|
|
battleScenarios,
|
|
memoryModule.firstPursuitScoutChoiceIds[0]
|
|
);
|
|
const sourceBattleId = memoryModule.firstPursuitScoutSourceBattleId;
|
|
const targetBattleId = memoryModule.firstPursuitScoutTargetBattleId;
|
|
const visitId = memoryModule.firstPursuitScoutVisitId;
|
|
const cases = [
|
|
{
|
|
label: 'missing source settlement',
|
|
mutate(campaign) {
|
|
delete campaign.battleHistory[sourceBattleId];
|
|
}
|
|
},
|
|
{
|
|
label: 'source defeat',
|
|
mutate(campaign) {
|
|
campaign.battleHistory[sourceBattleId].outcome = 'defeat';
|
|
}
|
|
},
|
|
{
|
|
label: 'mismatched source settlement id',
|
|
mutate(campaign) {
|
|
campaign.battleHistory[sourceBattleId].battleId = targetBattleId;
|
|
}
|
|
},
|
|
{
|
|
label: 'missing visit completion',
|
|
mutate(campaign) {
|
|
campaign.completedCampVisits = [];
|
|
}
|
|
},
|
|
{
|
|
label: 'lookalike visit completion',
|
|
mutate(campaign) {
|
|
campaign.completedCampVisits = [`${visitId}-forged`];
|
|
}
|
|
},
|
|
{
|
|
label: 'missing choice marker',
|
|
mutate(campaign) {
|
|
delete campaign.campVisitChoiceIds[visitId];
|
|
}
|
|
},
|
|
{
|
|
label: 'unknown choice marker',
|
|
mutate(campaign) {
|
|
campaign.campVisitChoiceIds[visitId] = 'reveal-all-enemies';
|
|
}
|
|
},
|
|
{
|
|
label: 'choice stored under a forged visit key',
|
|
mutate(campaign) {
|
|
campaign.campVisitChoiceIds[`${visitId}-forged`] =
|
|
campaign.campVisitChoiceIds[visitId];
|
|
delete campaign.campVisitChoiceIds[visitId];
|
|
}
|
|
}
|
|
];
|
|
|
|
cases.forEach(({ label, mutate }) => {
|
|
const campaign = structuredClone(valid);
|
|
mutate(campaign);
|
|
const memory = memoryModule.resolveFirstPursuitScoutMemory({
|
|
campaign,
|
|
battleId: targetBattleId
|
|
});
|
|
assert(
|
|
memory === undefined,
|
|
`Invalid scout state (${label}) must not grant deployment intent preview: ${JSON.stringify(memory)}`
|
|
);
|
|
});
|
|
}
|
|
|
|
function verifyTargetIsolation(
|
|
memoryModule,
|
|
campaignModule,
|
|
battleScenarios
|
|
) {
|
|
const campaign = createCompletedScoutCampaign(
|
|
memoryModule,
|
|
campaignModule,
|
|
battleScenarios,
|
|
memoryModule.firstPursuitScoutChoiceIds[1]
|
|
);
|
|
const unrelatedBattleIds = [
|
|
memoryModule.firstPursuitScoutSourceBattleId,
|
|
'third-battle-guangzong-road'
|
|
];
|
|
unrelatedBattleIds.forEach((battleId) => {
|
|
const memory = memoryModule.resolveFirstPursuitScoutMemory({
|
|
campaign,
|
|
battleId
|
|
});
|
|
assert(
|
|
memory === undefined,
|
|
`Scout payoff must not leak from the second battle into ${battleId}.`
|
|
);
|
|
});
|
|
}
|
|
|
|
function verifyGuardedExplorationCompletion(
|
|
memoryModule,
|
|
visitModule,
|
|
campaignModule,
|
|
actionModule,
|
|
battleScenarios
|
|
) {
|
|
storage.clear();
|
|
campaignModule.resetCampaignState();
|
|
assert(
|
|
actionModule.completeFirstPursuitScoutVisit(
|
|
memoryModule.firstPursuitScoutChoiceIds[0]
|
|
).reason === 'invalid-campaign',
|
|
'The exploration action must reject scout rewards before the first victory camp.'
|
|
);
|
|
|
|
const scenario =
|
|
battleScenarios[memoryModule.firstPursuitScoutSourceBattleId];
|
|
campaignModule.setFirstBattleReport({
|
|
battleId: scenario.id,
|
|
battleTitle: scenario.title,
|
|
outcome: 'victory',
|
|
turnNumber: 6,
|
|
rewardGold: scenario.baseVictoryGold,
|
|
defeatedEnemies: scenario.units.filter(
|
|
(unit) => unit.faction === 'enemy'
|
|
).length,
|
|
totalEnemies: scenario.units.filter(
|
|
(unit) => unit.faction === 'enemy'
|
|
).length,
|
|
objectives: [],
|
|
units: scenario.units,
|
|
bonds: scenario.bonds.map((bond) => ({ ...bond, battleExp: 0 })),
|
|
itemRewards: [],
|
|
completedCampDialogues: [],
|
|
completedCampVisits: [],
|
|
createdAt: '2026-07-27T00:00:00.000Z'
|
|
});
|
|
assert(
|
|
actionModule.completeFirstPursuitScoutVisit('forged-choice').reason ===
|
|
'invalid-choice',
|
|
'The exploration action must reject a forged scout choice.'
|
|
);
|
|
|
|
const choice = visitModule.firstPursuitScoutVisitDefinition.choices[0];
|
|
const beforeFailedSave = scoutProgressSnapshot(
|
|
campaignModule.getCampaignState(),
|
|
memoryModule.firstPursuitScoutVisitId
|
|
);
|
|
let failedSave;
|
|
rejectStorageWrites = true;
|
|
try {
|
|
failedSave = actionModule.completeFirstPursuitScoutVisit(choice.id);
|
|
} finally {
|
|
rejectStorageWrites = false;
|
|
}
|
|
const afterFailedSave = scoutProgressSnapshot(
|
|
campaignModule.getCampaignState(),
|
|
memoryModule.firstPursuitScoutVisitId
|
|
);
|
|
assert(
|
|
failedSave?.ok === false &&
|
|
failedSave.reason === 'save-unavailable' &&
|
|
JSON.stringify(afterFailedSave) === JSON.stringify(beforeFailedSave),
|
|
`A failed campaign write must restore the in-memory scout state: ${JSON.stringify({
|
|
failedSave,
|
|
beforeFailedSave,
|
|
afterFailedSave
|
|
})}`
|
|
);
|
|
|
|
const completed = actionModule.completeFirstPursuitScoutVisit(choice.id);
|
|
assert(
|
|
completed.ok === true &&
|
|
completed.choice.id === choice.id &&
|
|
completed.campaign.completedCampVisits.includes(
|
|
memoryModule.firstPursuitScoutVisitId
|
|
) &&
|
|
completed.campaign.campVisitChoiceIds[
|
|
memoryModule.firstPursuitScoutVisitId
|
|
] === choice.id,
|
|
'A valid exploration choice must atomically save completion and its canonical choice id.'
|
|
);
|
|
assert(
|
|
choice.itemRewards.every((reward) => {
|
|
const match = reward.match(/^(.+?)\s+([+-]?\d+)$/);
|
|
return Boolean(
|
|
match &&
|
|
(completed.campaign.inventory[match[1]] ?? 0) >=
|
|
Number.parseInt(match[2], 10)
|
|
);
|
|
}),
|
|
'A valid exploration choice must save its item reward.'
|
|
);
|
|
assert(
|
|
actionModule.completeFirstPursuitScoutVisit(choice.id).reason ===
|
|
'already-completed',
|
|
'The exploration action must reject duplicate rewards.'
|
|
);
|
|
}
|
|
|
|
async function verifyCampExplorationWiring(memoryModule, visitModule) {
|
|
const campSource = await readFile(
|
|
new URL('../src/game/scenes/CampScene.ts', import.meta.url),
|
|
'utf8'
|
|
);
|
|
const explorationSource = await readFile(
|
|
new URL(
|
|
'../src/game/scenes/CampVisitExplorationScene.ts',
|
|
import.meta.url
|
|
),
|
|
'utf8'
|
|
);
|
|
|
|
assert(
|
|
campSource.includes(
|
|
"import { firstPursuitScoutVisitDefinition } from '../data/firstPursuitScoutVisit';"
|
|
),
|
|
'CampScene must import the canonical first-pursuit visit definition.'
|
|
);
|
|
assert(
|
|
campSource.includes("'CampVisitExplorationScene'") &&
|
|
campSource.includes('정찰막으로 이동'),
|
|
'An incomplete first-pursuit visit must lead to the walkable camp exploration scene.'
|
|
);
|
|
assert(
|
|
explorationSource.includes(
|
|
"completeFirstPursuitScoutVisit(choice.id)"
|
|
) &&
|
|
explorationSource.includes("textureKey: 'exploration-jian-yong'"),
|
|
'The exploration scene must use the guarded action and Jian Yong dedicated SD sheet.'
|
|
);
|
|
|
|
const campaign = createLiteralSyncCampaign(memoryModule);
|
|
visitModule.firstPursuitScoutVisitDefinition.choices.forEach((choice) => {
|
|
campaign.campVisitChoiceIds[memoryModule.firstPursuitScoutVisitId] =
|
|
choice.id;
|
|
const memory = memoryModule.resolveFirstPursuitScoutMemory({
|
|
campaign,
|
|
battleId: memoryModule.firstPursuitScoutTargetBattleId
|
|
});
|
|
assert(
|
|
memory?.choiceLabel === choice.label,
|
|
`Visit and battle memory labels must remain synchronized for ${choice.id}.`
|
|
);
|
|
});
|
|
}
|
|
|
|
function createCompletedScoutCampaign(
|
|
memoryModule,
|
|
campaignModule,
|
|
battleScenarios,
|
|
choiceId
|
|
) {
|
|
storage.clear();
|
|
campaignModule.resetCampaignState();
|
|
const scenario =
|
|
battleScenarios[memoryModule.firstPursuitScoutSourceBattleId];
|
|
campaignModule.setFirstBattleReport({
|
|
battleId: scenario.id,
|
|
battleTitle: scenario.title,
|
|
outcome: 'victory',
|
|
turnNumber: 6,
|
|
rewardGold: scenario.baseVictoryGold,
|
|
defeatedEnemies: scenario.units.filter(
|
|
(unit) => unit.faction === 'enemy'
|
|
).length,
|
|
totalEnemies: scenario.units.filter(
|
|
(unit) => unit.faction === 'enemy'
|
|
).length,
|
|
objectives: [],
|
|
units: scenario.units,
|
|
bonds: scenario.bonds.map((bond) => ({ ...bond, battleExp: 0 })),
|
|
itemRewards: [],
|
|
completedCampDialogues: [],
|
|
completedCampVisits: [],
|
|
createdAt: '2026-07-27T00:00:00.000Z'
|
|
});
|
|
const updated = campaignModule.applyCampVisitReward(
|
|
memoryModule.firstPursuitScoutVisitId,
|
|
{},
|
|
choiceId
|
|
);
|
|
assert(
|
|
updated,
|
|
`Campaign state must accept canonical scout choice ${choiceId}.`
|
|
);
|
|
return campaignModule.getCampaignState();
|
|
}
|
|
|
|
function createLiteralSyncCampaign(memoryModule) {
|
|
return {
|
|
battleHistory: {
|
|
[memoryModule.firstPursuitScoutSourceBattleId]: {
|
|
battleId: memoryModule.firstPursuitScoutSourceBattleId,
|
|
outcome: 'victory'
|
|
}
|
|
},
|
|
completedCampVisits: [memoryModule.firstPursuitScoutVisitId],
|
|
campVisitChoiceIds: {}
|
|
};
|
|
}
|
|
|
|
function scoutProgressSnapshot(campaign, visitId) {
|
|
return {
|
|
completedCampVisits: [...campaign.completedCampVisits],
|
|
reportCompletedCampVisits: [
|
|
...(campaign.firstBattleReport?.completedCampVisits ?? [])
|
|
],
|
|
campVisitChoiceIds: { ...campaign.campVisitChoiceIds },
|
|
inventory: { ...campaign.inventory },
|
|
bonds: campaign.bonds.map(({ id, level, exp, battleExp }) => ({
|
|
id,
|
|
level,
|
|
exp,
|
|
battleExp
|
|
})),
|
|
visitCompleted: campaign.completedCampVisits.includes(visitId),
|
|
visitChoiceId: campaign.campVisitChoiceIds[visitId] ?? null
|
|
};
|
|
}
|
|
|
|
function assert(condition, message) {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
}
|