feat: carry first-battle camaraderie into sortie prep
This commit is contained in:
450
scripts/verify-first-battle-camaraderie-memory.mjs
Normal file
450
scripts/verify-first-battle-camaraderie-memory.mjs
Normal file
@@ -0,0 +1,450 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user