feat: add first-victory camp exploration
This commit is contained in:
@@ -2,12 +2,16 @@ 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) {
|
||||
@@ -29,21 +33,35 @@ 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);
|
||||
await verifyCampSceneLiteralSync(memoryModule);
|
||||
verifyGuardedExplorationCompletion(
|
||||
memoryModule,
|
||||
visitModule,
|
||||
campaignModule,
|
||||
actionModule,
|
||||
battleScenarios
|
||||
);
|
||||
await verifyCampExplorationWiring(memoryModule, visitModule);
|
||||
|
||||
console.log(
|
||||
'First-pursuit scout memory verification passed (canonical visit data, strict marker validation, target isolation, and CampScene literal sync).'
|
||||
'First-pursuit scout verification passed (canonical visit data, guarded exploration completion, strict memory validation, target isolation, and camp-scene wiring).'
|
||||
);
|
||||
} finally {
|
||||
await server.close();
|
||||
@@ -68,6 +86,35 @@ function verifyCanonicalIds(memoryModule) {
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -224,48 +271,150 @@ function verifyTargetIsolation(
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyCampSceneLiteralSync(memoryModule) {
|
||||
const source = await readFile(
|
||||
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 visitsStart = source.indexOf(
|
||||
'const campVisits: CampVisitDefinition[] = ['
|
||||
);
|
||||
const visitStart = source.indexOf(
|
||||
`id: '${memoryModule.firstPursuitScoutVisitId}'`,
|
||||
visitsStart
|
||||
);
|
||||
assert(
|
||||
visitsStart >= 0 && visitStart > visitsStart,
|
||||
'CampScene must declare the canonical first-pursuit visit inside campVisits.'
|
||||
const explorationSource = await readFile(
|
||||
new URL(
|
||||
'../src/game/scenes/CampVisitExplorationScene.ts',
|
||||
import.meta.url
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const visitSource = source.slice(visitStart, visitStart + 2600);
|
||||
assert(
|
||||
/availableAfterBattleIds:\s*\[\s*campBattleIds\.first\s*\]/.test(
|
||||
visitSource
|
||||
campSource.includes(
|
||||
"import { firstPursuitScoutVisitDefinition } from '../data/firstPursuitScoutVisit';"
|
||||
),
|
||||
'The canonical scout visit must be available immediately after the first battle.'
|
||||
'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.'
|
||||
);
|
||||
memoryModule.firstPursuitScoutChoiceIds.forEach((choiceId) => {
|
||||
assert(
|
||||
visitSource.includes(`id: '${choiceId}'`),
|
||||
`CampScene scout visit is missing canonical choice id ${choiceId}.`
|
||||
);
|
||||
});
|
||||
|
||||
const campaign = createLiteralSyncCampaign(memoryModule);
|
||||
memoryModule.firstPursuitScoutChoiceIds.forEach((choiceId) => {
|
||||
visitModule.firstPursuitScoutVisitDefinition.choices.forEach((choice) => {
|
||||
campaign.campVisitChoiceIds[memoryModule.firstPursuitScoutVisitId] =
|
||||
choiceId;
|
||||
choice.id;
|
||||
const memory = memoryModule.resolveFirstPursuitScoutMemory({
|
||||
campaign,
|
||||
battleId: memoryModule.firstPursuitScoutTargetBattleId
|
||||
});
|
||||
assert(
|
||||
visitSource.includes(`label: '${memory.choiceLabel}'`),
|
||||
`CampScene label for ${choiceId} must match the canonical memory label.`
|
||||
memory?.choiceLabel === choice.label,
|
||||
`Visit and battle memory labels must remain synchronized for ${choice.id}.`
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -325,6 +474,25 @@ function createLiteralSyncCampaign(memoryModule) {
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user