Files
heros_web/scripts/verify-first-battle-camaraderie-memory.mjs

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);
}
}