451 lines
13 KiB
JavaScript
451 lines
13 KiB
JavaScript
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, hmr: false },
|
|
appType: 'custom'
|
|
});
|
|
|
|
try {
|
|
const memoryModule = await server.ssrLoadModule(
|
|
'/src/game/data/firstBattleCamaraderieMemory.ts'
|
|
);
|
|
const campaignModule = await server.ssrLoadModule(
|
|
'/src/game/state/campaignState.ts'
|
|
);
|
|
const battleSaveModule = await server.ssrLoadModule(
|
|
'/src/game/state/battleSaveState.ts'
|
|
);
|
|
const { battleScenarios } = await server.ssrLoadModule(
|
|
'/src/game/data/battles.ts'
|
|
);
|
|
|
|
verifyDominantSelection(memoryModule);
|
|
verifyMemoryAndBonus(memoryModule);
|
|
verifyCampaignRoundTrip(memoryModule, campaignModule, battleScenarios);
|
|
verifyBattleSaveRoundTrip(battleSaveModule);
|
|
|
|
console.log(
|
|
'First-battle camaraderie memory verification passed (selection, campaign settlement, legacy battle save, and +5%p gate).'
|
|
);
|
|
} finally {
|
|
await server.close();
|
|
}
|
|
|
|
function verifyDominantSelection(memoryModule) {
|
|
const bonds = [
|
|
{ id: 'bond-c', unitIds: ['liu-bei', 'guan-yu'] },
|
|
{ id: 'bond-a', unitIds: ['liu-bei', 'zhang-fei'] },
|
|
{ id: 'bond-b', unitIds: ['guan-yu', 'zhang-fei'] }
|
|
];
|
|
const statsByBond = {
|
|
'bond-c': { attempts: 8, successes: 0, additionalDamage: 80 },
|
|
'bond-b': { attempts: 2, successes: 1, additionalDamage: 6 },
|
|
'bond-a': { attempts: 3, successes: 1, additionalDamage: 6 }
|
|
};
|
|
const snapshot = memoryModule.selectDominantSortieCooperationSnapshot({
|
|
bonds,
|
|
statsByBond
|
|
});
|
|
assert(
|
|
snapshot?.bondId === 'bond-a' &&
|
|
snapshot.attempts === 3 &&
|
|
snapshot.successes === 1 &&
|
|
snapshot.additionalDamage === 6,
|
|
`A successful pair must outrank a failure-only pair, then use success, damage, attempts, and stable id: ${JSON.stringify(snapshot)}`
|
|
);
|
|
snapshot.unitIds[0] = 'mutated';
|
|
assert(
|
|
bonds[1].unitIds[0] === 'liu-bei',
|
|
'Dominant selection must clone the canonical bond pair.'
|
|
);
|
|
|
|
const failureOnly = memoryModule.selectDominantSortieCooperationSnapshot({
|
|
bonds: [{ id: 'bond-c', unitIds: ['liu-bei', 'guan-yu'] }],
|
|
statsByBond: {
|
|
'bond-c': { attempts: 2.9, successes: -4, additionalDamage: 99 }
|
|
}
|
|
});
|
|
assert(
|
|
failureOnly?.attempts === 2 &&
|
|
failureOnly.successes === 0 &&
|
|
failureOnly.additionalDamage === 0,
|
|
`Failure-only cooperation must discard impossible damage: ${JSON.stringify(failureOnly)}`
|
|
);
|
|
}
|
|
|
|
function verifyMemoryAndBonus(memoryModule) {
|
|
const snapshot = {
|
|
version: 1,
|
|
bondId: 'liu-bei__zhang-fei',
|
|
unitIds: ['liu-bei', 'zhang-fei'],
|
|
attempts: 1,
|
|
successes: 1,
|
|
additionalDamage: 7
|
|
};
|
|
const campaign = {
|
|
firstBattleReport: {
|
|
battleId: 'second-battle-yellow-turban-pursuit',
|
|
outcome: 'defeat'
|
|
},
|
|
battleHistory: {
|
|
'first-battle-zhuo-commandery': {
|
|
battleId: 'first-battle-zhuo-commandery',
|
|
outcome: 'victory',
|
|
units: [
|
|
{ unitId: 'liu-bei', name: '유비' },
|
|
{ unitId: 'zhang-fei', name: '장비' }
|
|
],
|
|
sortieCooperation: snapshot
|
|
},
|
|
'second-battle-yellow-turban-pursuit': {
|
|
battleId: 'second-battle-yellow-turban-pursuit',
|
|
outcome: 'defeat',
|
|
units: []
|
|
}
|
|
},
|
|
completedCampDialogues: []
|
|
};
|
|
const memory = memoryModule.resolveFirstBattleCamaraderieMemory(campaign);
|
|
assert(
|
|
memory?.bondId === snapshot.bondId &&
|
|
memory.rememberedOutcome === 'success' &&
|
|
memory.completed === false &&
|
|
memory.dialogue.id === memoryModule.firstBattleCamaraderieDialogueId &&
|
|
memory.dialogue.lines.length === 3 &&
|
|
memory.dialogue.choices.every((choice) => choice.response.includes('+5%p')),
|
|
`Memory must come from the first victory settlement and explain the unlock for either choice: ${JSON.stringify(memory)}`
|
|
);
|
|
assert(
|
|
memoryModule.firstBattleCamaraderieBonusFor({
|
|
campaign,
|
|
battleId: memoryModule.firstBattleCamaraderieTargetBattleId,
|
|
coreBondId: snapshot.bondId
|
|
}) === 0,
|
|
'The remembered pair must not receive a bonus before its camp dialogue is completed.'
|
|
);
|
|
|
|
campaign.completedCampDialogues.push(
|
|
memoryModule.firstBattleCamaraderieDialogueId
|
|
);
|
|
assert(
|
|
memoryModule.firstBattleCamaraderieBonusFor({
|
|
campaign,
|
|
battleId: memoryModule.firstBattleCamaraderieTargetBattleId,
|
|
coreBondId: snapshot.bondId
|
|
}) === 5 &&
|
|
memoryModule.firstBattleCamaraderieBonusFor({
|
|
campaign,
|
|
battleId: memoryModule.firstBattleCamaraderieTargetBattleId,
|
|
coreBondId: 'liu-bei__guan-yu'
|
|
}) === 0 &&
|
|
memoryModule.firstBattleCamaraderieBonusFor({
|
|
campaign,
|
|
battleId: 'third-battle-guangzong-road',
|
|
coreBondId: snapshot.bondId
|
|
}) === 0,
|
|
'The +5%p bonus must require dialogue completion, the exact next battle, and the same manually selected core pair.'
|
|
);
|
|
assert(
|
|
memoryModule.resolveFirstBattleCamaraderieMemory({
|
|
battleHistory: {
|
|
'first-battle-zhuo-commandery': {
|
|
...campaign.battleHistory['first-battle-zhuo-commandery'],
|
|
outcome: 'defeat'
|
|
}
|
|
}
|
|
}) === undefined,
|
|
'A first-battle defeat must never create a camaraderie memory.'
|
|
);
|
|
|
|
const attemptedCampaign = structuredClone(campaign);
|
|
attemptedCampaign.completedCampDialogues = [];
|
|
attemptedCampaign.battleHistory[
|
|
memoryModule.firstBattleCamaraderieSourceBattleId
|
|
].sortieCooperation = {
|
|
...snapshot,
|
|
attempts: 2,
|
|
successes: 0,
|
|
additionalDamage: 99
|
|
};
|
|
const attemptedMemory =
|
|
memoryModule.resolveFirstBattleCamaraderieMemory(attemptedCampaign);
|
|
assert(
|
|
attemptedMemory?.rememberedOutcome === 'attempted' &&
|
|
attemptedMemory.attempts === 2 &&
|
|
attemptedMemory.successes === 0 &&
|
|
attemptedMemory.additionalDamage === 0 &&
|
|
attemptedMemory.dialogue.title.includes('엇갈린') &&
|
|
attemptedMemory.dialogue.lines.length === 3 &&
|
|
attemptedMemory.dialogue.choices.every(
|
|
(choice) => choice.response.includes('+5%p')
|
|
),
|
|
`A failed-only pursuit must become a safe learning dialogue with no phantom damage: ${JSON.stringify(attemptedMemory)}`
|
|
);
|
|
}
|
|
|
|
function verifyCampaignRoundTrip(
|
|
memoryModule,
|
|
campaignModule,
|
|
battleScenarios
|
|
) {
|
|
storage.clear();
|
|
campaignModule.resetCampaignState();
|
|
const scenario =
|
|
battleScenarios[memoryModule.firstBattleCamaraderieSourceBattleId];
|
|
const cooperation = {
|
|
version: 1,
|
|
bondId: 'liu-bei__zhang-fei',
|
|
unitIds: ['liu-bei', 'zhang-fei'],
|
|
attempts: 3,
|
|
successes: 1,
|
|
additionalDamage: 8
|
|
};
|
|
campaignModule.setFirstBattleReport({
|
|
battleId: scenario.id,
|
|
battleTitle: scenario.title,
|
|
outcome: 'victory',
|
|
turnNumber: 5,
|
|
rewardGold: 100,
|
|
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: [],
|
|
sortieCooperation: cooperation,
|
|
completedCampDialogues: [],
|
|
completedCampVisits: [],
|
|
createdAt: '2026-07-27T00:00:00.000Z'
|
|
});
|
|
|
|
let campaign = campaignModule.getCampaignState();
|
|
const settlement =
|
|
campaign.battleHistory[memoryModule.firstBattleCamaraderieSourceBattleId];
|
|
assert(
|
|
JSON.stringify(campaign.firstBattleReport?.sortieCooperation) ===
|
|
JSON.stringify(cooperation) &&
|
|
JSON.stringify(settlement?.sortieCooperation) ===
|
|
JSON.stringify(cooperation) &&
|
|
campaign.firstBattleReport.sortieCooperation !==
|
|
settlement.sortieCooperation &&
|
|
campaign.firstBattleReport.sortieCooperation.unitIds !==
|
|
settlement.sortieCooperation.unitIds,
|
|
'Report-to-settlement creation must preserve and deep-clone the selected cooperation snapshot.'
|
|
);
|
|
|
|
campaign = campaignModule.loadCampaignState();
|
|
assert(
|
|
campaign.battleHistory[memoryModule.firstBattleCamaraderieSourceBattleId]
|
|
?.sortieCooperation?.bondId === cooperation.bondId,
|
|
'A valid cooperation snapshot must survive a persisted campaign round trip.'
|
|
);
|
|
const failedOnly = campaignModule.normalizeCampaignSortieCooperationSnapshot(
|
|
{
|
|
...cooperation,
|
|
attempts: 2.8,
|
|
successes: -1,
|
|
additionalDamage: 100
|
|
},
|
|
{
|
|
allowedUnitIds: new Set(cooperation.unitIds),
|
|
bonds: scenario.bonds
|
|
}
|
|
);
|
|
assert(
|
|
failedOnly?.attempts === 2 &&
|
|
failedOnly.successes === 0 &&
|
|
failedOnly.additionalDamage === 0,
|
|
`Campaign normalization must canonicalize failed-only totals: ${JSON.stringify(failedOnly)}`
|
|
);
|
|
|
|
const raw = JSON.parse(storage.get(campaignModule.campaignStorageKey));
|
|
raw.firstBattleReport.sortieCooperation.unitIds[1] = 'ghost-unit';
|
|
raw.battleHistory[
|
|
memoryModule.firstBattleCamaraderieSourceBattleId
|
|
].sortieCooperation.bondId = 'ghost-bond';
|
|
storage.set(campaignModule.campaignStorageKey, JSON.stringify(raw));
|
|
campaign = campaignModule.loadCampaignState();
|
|
assert(
|
|
campaign.firstBattleReport?.sortieCooperation === undefined &&
|
|
campaign.battleHistory[
|
|
memoryModule.firstBattleCamaraderieSourceBattleId
|
|
]?.sortieCooperation === undefined,
|
|
'Unknown roster and bond references must be removed without rejecting the campaign save.'
|
|
);
|
|
}
|
|
|
|
function verifyBattleSaveRoundTrip(battleSaveModule) {
|
|
const options = {
|
|
expectedBattleId: 'first-battle-zhuo-commandery',
|
|
mapWidth: 12,
|
|
mapHeight: 8,
|
|
validUnitIds: new Set(['liu-bei', 'guan-yu', 'rebel-1']),
|
|
validAllyUnitIds: new Set(['liu-bei', 'guan-yu']),
|
|
validEnemyUnitIds: new Set(['rebel-1']),
|
|
validUsableIds: new Set(['bean']),
|
|
validItemIds: new Set(['bean']),
|
|
validEquipmentItemIds: new Set([
|
|
'training-sword',
|
|
'cloth-armor',
|
|
'grain-pouch'
|
|
]),
|
|
validEquipmentSlotItems: {
|
|
weapon: new Set(['training-sword']),
|
|
armor: new Set(['cloth-armor']),
|
|
accessory: new Set(['grain-pouch'])
|
|
},
|
|
validTriggeredBattleEventIds: new Set(['opening'])
|
|
};
|
|
const base = createValidBattleSaveState();
|
|
const normalized = battleSaveModule.normalizeBattleSaveState(
|
|
{
|
|
...base,
|
|
cooperationStatsByBond: {
|
|
taoyuan: {
|
|
attempts: 2.9,
|
|
successes: -1,
|
|
additionalDamage: 100
|
|
},
|
|
'ghost-bond': {
|
|
attempts: 9,
|
|
successes: 9,
|
|
additionalDamage: 99
|
|
}
|
|
}
|
|
},
|
|
options
|
|
);
|
|
assert(
|
|
JSON.stringify(normalized?.cooperationStatsByBond) ===
|
|
JSON.stringify({
|
|
taoyuan: { attempts: 2, successes: 0, additionalDamage: 0 }
|
|
}),
|
|
`Battle saves must normalize per-bond totals and discard unknown bonds: ${JSON.stringify(normalized?.cooperationStatsByBond)}`
|
|
);
|
|
|
|
const legacy = battleSaveModule.normalizeBattleSaveState(
|
|
{
|
|
...base,
|
|
sortieResonanceBondId: 'taoyuan',
|
|
coreResonanceStats: {
|
|
attempts: 3,
|
|
successes: 1,
|
|
failures: 2,
|
|
additionalDamage: 9
|
|
}
|
|
},
|
|
options
|
|
);
|
|
assert(
|
|
JSON.stringify(legacy?.cooperationStatsByBond) ===
|
|
JSON.stringify({
|
|
taoyuan: { attempts: 3, successes: 1, additionalDamage: 9 }
|
|
}),
|
|
`Legacy core-resonance totals must backfill the generic per-bond record: ${JSON.stringify(legacy?.cooperationStatsByBond)}`
|
|
);
|
|
assert(
|
|
!battleSaveModule.isValidBattleSaveState(
|
|
{
|
|
...base,
|
|
cooperationStatsByBond: {
|
|
taoyuan: { attempts: 1, successes: 0, additionalDamage: 7 }
|
|
}
|
|
},
|
|
options
|
|
),
|
|
'Direct validation must reject impossible failure-only additional damage.'
|
|
);
|
|
}
|
|
|
|
function createValidBattleSaveState() {
|
|
return {
|
|
version: 1,
|
|
battleId: 'first-battle-zhuo-commandery',
|
|
campaignStep: 'first-battle',
|
|
sortieOrderId: 'elite',
|
|
savedAt: '2026-07-27T00:00:00.000Z',
|
|
turnNumber: 2,
|
|
activeFaction: 'ally',
|
|
rosterTab: 'ally',
|
|
actedUnitIds: [],
|
|
attackIntents: [],
|
|
battleLog: [],
|
|
units: [
|
|
savedUnit('liu-bei', 1, 2),
|
|
savedUnit('guan-yu', 2, 2),
|
|
savedUnit('rebel-1', 8, 4)
|
|
],
|
|
bonds: [
|
|
{
|
|
id: 'taoyuan',
|
|
unitIds: ['liu-bei', 'guan-yu'],
|
|
title: 'Oath',
|
|
level: 30,
|
|
exp: 0,
|
|
battleExp: 0,
|
|
description: 'Shared oath.'
|
|
}
|
|
],
|
|
itemStocks: {},
|
|
battleBuffs: [],
|
|
battleStatuses: [],
|
|
battleStats: {},
|
|
enemyUsableUseKeys: [],
|
|
triggeredBattleEvents: []
|
|
};
|
|
}
|
|
|
|
function savedUnit(id, x, y) {
|
|
return {
|
|
id,
|
|
level: 2,
|
|
exp: 0,
|
|
hp: 30,
|
|
maxHp: 30,
|
|
attack: 10,
|
|
move: 4,
|
|
stats: {
|
|
might: 70,
|
|
intelligence: 70,
|
|
leadership: 70,
|
|
agility: 70,
|
|
luck: 70
|
|
},
|
|
x,
|
|
y,
|
|
direction: 'south',
|
|
equipment: {
|
|
weapon: { itemId: 'training-sword', level: 1, exp: 0 },
|
|
armor: { itemId: 'cloth-armor', level: 1, exp: 0 },
|
|
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
|
|
}
|
|
};
|
|
}
|
|
|
|
function assert(condition, message) {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
}
|