feat: add selectable core resonance pairs

This commit is contained in:
2026-07-11 22:05:33 +09:00
parent 7d3c49ac0b
commit 51529e51c2
9 changed files with 1693 additions and 100 deletions

View File

@@ -44,6 +44,7 @@ try {
setCampaignReserveTrainingFocus,
setCampaignReserveTrainingAssignment,
setCampaignSortieOrderSelection,
setCampaignSortieResonanceSelection,
setFirstBattleReport
} = await server.ssrLoadModule('/src/game/state/campaignState.ts');
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
@@ -491,6 +492,174 @@ try {
`Expected clearing the matching battle order to remove the scoped selection: ${JSON.stringify(clearedOrderSelection.sortieOrderSelection)}`
);
const coreResonanceFixture = {
version: 1,
updatedAt: '2026-07-03T11:15:00.000Z',
step: 'first-camp',
roster: [
{ id: 'liu-bei', name: 'Liu Bei', faction: 'ally' },
{ id: 'guan-yu', name: 'Guan Yu', faction: 'ally' },
{ id: 'zhang-fei', name: 'Zhang Fei', faction: 'ally' }
],
bonds: [
{ id: 'liu-bei__guan-yu', title: 'Oath Bond', unitIds: ['liu-bei', 'guan-yu'], level: 30, exp: 0, battleExp: 0 },
{ id: 'liu-bei__zhang-fei', title: 'Young Bond', unitIds: ['liu-bei', 'zhang-fei'], level: 29, exp: 0, battleExp: 0 }
],
selectedSortieUnitIds: ['liu-bei', 'guan-yu'],
sortieResonanceSelection: {
battleId: 'first-battle-zhuo-commandery',
bondId: 'liu-bei__guan-yu',
extra: true
}
};
storage.clear();
storage.set(campaignStorageKey, JSON.stringify(coreResonanceFixture));
const normalizedCoreResonance = loadCampaignState();
assert(
JSON.stringify(normalizedCoreResonance.sortieResonanceSelection) === JSON.stringify({
battleId: 'first-battle-zhuo-commandery',
bondId: 'liu-bei__guan-yu'
}),
`Expected an eligible deployed Lv30 bond to retain a battle-scoped core resonance selection: ${JSON.stringify(normalizedCoreResonance.sortieResonanceSelection)}`
);
const detachedCoreResonance = getCampaignState();
detachedCoreResonance.sortieResonanceSelection.bondId = 'mutated';
assert(
getCampaignState().sortieResonanceSelection?.bondId === 'liu-bei__guan-yu',
'Expected core resonance selections returned from campaign state to be deep clones.'
);
const invalidCoreResonanceSet = setCampaignSortieResonanceSelection('first-battle-zhuo-commandery', 'liu-bei__zhang-fei');
const unknownCoreResonanceBattle = setCampaignSortieResonanceSelection('unknown-battle', 'liu-bei__guan-yu');
const prototypeKeyCoreResonanceBattle = setCampaignSortieResonanceSelection('constructor', 'liu-bei__guan-yu');
assert(
invalidCoreResonanceSet.sortieResonanceSelection?.bondId === 'liu-bei__guan-yu' &&
unknownCoreResonanceBattle.sortieResonanceSelection?.bondId === 'liu-bei__guan-yu' &&
prototypeKeyCoreResonanceBattle.sortieResonanceSelection?.bondId === 'liu-bei__guan-yu',
`Expected ineligible bonds and unknown/prototype-key battles not to disturb the current core resonance: ${JSON.stringify({ invalidCoreResonanceSet, unknownCoreResonanceBattle, prototypeKeyCoreResonanceBattle })}`
);
const clearedOtherBattleCoreResonance = setCampaignSortieResonanceSelection('second-battle-yellow-turban-pursuit');
assert(
clearedOtherBattleCoreResonance.sortieResonanceSelection?.bondId === 'liu-bei__guan-yu',
'Expected clearing another battle not to remove the current battle-scoped core resonance.'
);
const clearedCoreResonance = setCampaignSortieResonanceSelection('first-battle-zhuo-commandery');
assert(clearedCoreResonance.sortieResonanceSelection === undefined, 'Expected the matching core resonance selection to clear explicitly.');
const selectedCoreResonance = setCampaignSortieResonanceSelection('second-battle-yellow-turban-pursuit', 'liu-bei__guan-yu');
const reloadedCoreResonance = loadCampaignState();
assert(
selectedCoreResonance.sortieResonanceSelection?.battleId === 'second-battle-yellow-turban-pursuit' &&
reloadedCoreResonance.sortieResonanceSelection?.bondId === 'liu-bei__guan-yu',
`Expected the setter to persist a valid core resonance selection: ${JSON.stringify(reloadedCoreResonance.sortieResonanceSelection)}`
);
storage.clear();
storage.set(campaignStorageKey, JSON.stringify(coreResonanceFixture));
loadCampaignState();
const memberRemovedCoreResonance = getCampaignState();
memberRemovedCoreResonance.selectedSortieUnitIds = ['liu-bei'];
const normalizedMemberRemovedCoreResonance = saveCampaignState(memberRemovedCoreResonance);
assert(
normalizedMemberRemovedCoreResonance.sortieResonanceSelection === undefined,
`Expected removing either core member from the sortie to clear the selection: ${JSON.stringify(normalizedMemberRemovedCoreResonance.sortieResonanceSelection)}`
);
const invalidCoreResonanceFixtures = [
{
label: 'unknown battle',
value: { ...coreResonanceFixture, sortieResonanceSelection: { battleId: 'unknown-battle', bondId: 'liu-bei__guan-yu' } }
},
{
label: 'prototype-key battle',
value: { ...coreResonanceFixture, sortieResonanceSelection: { battleId: 'constructor', bondId: 'liu-bei__guan-yu' } }
},
{
label: 'unknown bond',
value: { ...coreResonanceFixture, sortieResonanceSelection: { battleId: 'first-battle-zhuo-commandery', bondId: 'ghost-bond' } }
},
{
label: 'below Lv30',
value: {
...coreResonanceFixture,
selectedSortieUnitIds: ['liu-bei', 'zhang-fei'],
sortieResonanceSelection: { battleId: 'first-battle-zhuo-commandery', bondId: 'liu-bei__zhang-fei' }
}
},
{
label: 'undeployed member',
value: { ...coreResonanceFixture, selectedSortieUnitIds: ['liu-bei'] }
}
];
for (const fixture of invalidCoreResonanceFixtures) {
storage.clear();
storage.set(campaignStorageKey, JSON.stringify(fixture.value));
const normalizedInvalidCoreResonance = loadCampaignState();
assert(
normalizedInvalidCoreResonance.sortieResonanceSelection === undefined,
`Expected ${fixture.label} core resonance selection to normalize away: ${JSON.stringify(normalizedInvalidCoreResonance.sortieResonanceSelection)}`
);
}
const firstBattleBondIds = new Set(battleScenarios['first-battle-zhuo-commandery'].bonds.map((bond) => bond.id));
const crossBattleResonance = Object.entries(battleScenarios)
.filter(([battleId]) => battleId !== 'first-battle-zhuo-commandery')
.flatMap(([battleId, scenario]) => scenario.bonds.map((bond) => ({ battleId, bond })))
.find(({ bond }) => bond.level >= 30 && !firstBattleBondIds.has(bond.id));
assert(crossBattleResonance, 'Expected campaign scenarios to expose a later-battle-only eligible bond fixture.');
const crossBattleResonanceFixture = {
...coreResonanceFixture,
roster: crossBattleResonance.bond.unitIds.map((unitId) => ({ id: unitId, name: unitId, faction: 'ally' })),
bonds: [{ ...crossBattleResonance.bond, battleExp: 0 }],
selectedSortieUnitIds: [...crossBattleResonance.bond.unitIds],
sortieResonanceSelection: {
battleId: 'first-battle-zhuo-commandery',
bondId: crossBattleResonance.bond.id
}
};
storage.clear();
storage.set(campaignStorageKey, JSON.stringify(crossBattleResonanceFixture));
const normalizedCrossBattleResonance = loadCampaignState();
const rejectedCrossBattleResonance = setCampaignSortieResonanceSelection(
'first-battle-zhuo-commandery',
crossBattleResonance.bond.id
);
const acceptedOwningBattleResonance = setCampaignSortieResonanceSelection(
crossBattleResonance.battleId,
crossBattleResonance.bond.id
);
assert(
normalizedCrossBattleResonance.sortieResonanceSelection === undefined &&
rejectedCrossBattleResonance.sortieResonanceSelection === undefined &&
acceptedOwningBattleResonance.sortieResonanceSelection?.battleId === crossBattleResonance.battleId &&
loadCampaignState().sortieResonanceSelection?.bondId === crossBattleResonance.bond.id,
`Expected cross-battle bonds to be rejected while the same bond remains valid for its owning battle: ${JSON.stringify({ crossBattleResonance, normalizedCrossBattleResonance, rejectedCrossBattleResonance, acceptedOwningBattleResonance })}`
);
const scenarioOnlyResonanceFixture = {
...crossBattleResonanceFixture,
bonds: [],
sortieResonanceSelection: {
battleId: crossBattleResonance.battleId,
bondId: crossBattleResonance.bond.id
}
};
storage.clear();
storage.set(campaignStorageKey, JSON.stringify(scenarioOnlyResonanceFixture));
const normalizedScenarioOnlyResonance = loadCampaignState();
setCampaignSortieResonanceSelection(crossBattleResonance.battleId);
const selectedScenarioOnlyResonance = setCampaignSortieResonanceSelection(
crossBattleResonance.battleId,
crossBattleResonance.bond.id
);
const reloadedScenarioOnlyResonance = loadCampaignState();
assert(
normalizedScenarioOnlyResonance.bonds.length === 0 &&
normalizedScenarioOnlyResonance.sortieResonanceSelection?.bondId === crossBattleResonance.bond.id &&
selectedScenarioOnlyResonance.sortieResonanceSelection?.battleId === crossBattleResonance.battleId &&
reloadedScenarioOnlyResonance.bonds.length === 0 &&
reloadedScenarioOnlyResonance.sortieResonanceSelection?.bondId === crossBattleResonance.bond.id,
`Expected a scenario-owned eligible bond to remain selectable and reload safely before campaign progress contains it: ${JSON.stringify({ normalizedScenarioOnlyResonance, selectedScenarioOnlyResonance, reloadedScenarioOnlyResonance })}`
);
storage.clear();
storage.set(
campaignStorageKey,
@@ -1251,6 +1420,7 @@ try {
typeof legacy.sortieFormationPresets === 'object' &&
Object.keys(legacy.sortieFormationPresets).length === 0 &&
legacy.sortieOrderSelection === undefined &&
legacy.sortieResonanceSelection === undefined &&
typeof legacy.sortieOrderHistory === 'object' &&
Object.keys(legacy.sortieOrderHistory).length === 0 &&
Array.isArray(legacy.claimedSortieOrderRewardIds) &&