diff --git a/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs index 6b89c35..9c15350 100644 --- a/scripts/verify-campaign-save-normalization.mjs +++ b/scripts/verify-campaign-save-normalization.mjs @@ -247,7 +247,7 @@ try { roster: { corrupted: true }, bonds: [ { id: 'broken-bond', title: 'Broken Bond', unitIds: { corrupted: true }, level: 3, exp: 12, battleExp: 2 }, - { id: 'oath-bond', title: 'Oath Bond', unitIds: ['liu-bei', 'guan-yu'], level: '2', exp: '14', battleExp: '5' }, + { id: 'oath-bond', title: 'Oath Bond', unitIds: ['liu-bei', 'guan-yu'], level: '2', exp: '999', battleExp: '5' }, 9 ], completedCampDialogues: ['dialogue-a', 17, 'dialogue-a', '', 'x'.repeat(97)], @@ -283,6 +283,7 @@ try { malformed.bonds.length === 1 && malformed.bonds[0].id === 'oath-bond' && malformed.bonds[0].level === 2 && + malformed.bonds[0].exp === 99 && malformed.bonds[0].unitIds.length === 2, `Expected malformed bond entries to be filtered while valid bonds normalize: ${JSON.stringify(malformed)}` ); @@ -445,35 +446,65 @@ try { className: 'Lord', classKey: 'lost-class', level: '6', - exp: '18', + exp: '999', hp: '42', maxHp: '45', attack: '22', move: '5', stats: { might: '19', intelligence: '17', leadership: '21', agility: '13', luck: '12' }, equipment: { - weapon: { itemId: 'short-bow', level: '3', exp: '14' }, - armor: { itemId: 'short-bow', level: '2', exp: '9' }, - accessory: { itemId: 'grain-pouch', level: '0', exp: '7' } + weapon: { itemId: 'short-bow', level: '3', exp: '999' }, + armor: { itemId: 'short-bow', level: '2', exp: '999' }, + accessory: { itemId: 'grain-pouch', level: '0', exp: '999' } }, x: '2', y: '3' }, + { + id: 'guan-yu', + name: 'Guan Yu', + faction: 'ally', + className: 'General', + classKey: 'spearman', + level: '99', + exp: '999', + hp: '50', + maxHp: '50', + attack: '30', + move: '5', + stats: { might: '24', intelligence: '13', leadership: '22', agility: '14', luck: '11' }, + equipment: { + weapon: { itemId: 'iron-spear', level: '9', exp: '999' }, + armor: { itemId: 'lamellar-armor', level: '9', exp: '999' }, + accessory: { itemId: 'grain-pouch', level: '9', exp: '999' } + }, + x: '3', + y: '3' + }, { id: 'ghost-unit', name: 'Ghost', faction: 'neutral' } ] }) ); const malformedRoster = loadCampaignState(); + const normalizedLiuBei = malformedRoster.roster.find((unit) => unit.id === 'liu-bei'); + const normalizedGuanYu = malformedRoster.roster.find((unit) => unit.id === 'guan-yu'); assert( - malformedRoster.roster.length === 1 && - malformedRoster.roster[0].id === 'liu-bei' && - malformedRoster.roster[0].classKey === 'infantry' && - malformedRoster.roster[0].level === 6 && - malformedRoster.roster[0].equipment.weapon.itemId === 'short-bow' && - malformedRoster.roster[0].equipment.weapon.level === 3 && - malformedRoster.roster[0].equipment.armor.itemId === 'cloth-armor' && - malformedRoster.roster[0].equipment.accessory.itemId === 'grain-pouch' && - malformedRoster.roster[0].equipment.accessory.level === 1, + malformedRoster.roster.length === 2 && + normalizedLiuBei?.classKey === 'infantry' && + normalizedLiuBei.level === 6 && + normalizedLiuBei.exp === 99 && + normalizedLiuBei.equipment.weapon.itemId === 'short-bow' && + normalizedLiuBei.equipment.weapon.level === 3 && + normalizedLiuBei.equipment.weapon.exp === 109 && + normalizedLiuBei.equipment.armor.itemId === 'cloth-armor' && + normalizedLiuBei.equipment.armor.exp === 89 && + normalizedLiuBei.equipment.accessory.itemId === 'grain-pouch' && + normalizedLiuBei.equipment.accessory.level === 1 && + normalizedLiuBei.equipment.accessory.exp === 69 && + normalizedGuanYu?.level === 99 && + normalizedGuanYu.exp === 100 && + normalizedGuanYu.equipment.weapon.level === 9 && + normalizedGuanYu.equipment.weapon.exp === 230, `Expected malformed roster units and equipment to be normalized safely: ${JSON.stringify(malformedRoster)}` ); @@ -900,7 +931,7 @@ try { `Expected explicitly loaded corrupted slot timestamp to be normalized: ${JSON.stringify(corruptedSlot)}` ); - console.log('Verified campaign save normalization, reward resettlement, camp reward idempotence, malformed-field/roster-equipment/bond-roster/report-reference/sortie-roster/latest-history/detail recovery, timestamp-safe history/slot recovery, unknown-step/report/history recovery, unsupported-version rejection, and slotted fallback.'); + console.log('Verified campaign save normalization, reward resettlement, camp reward idempotence, malformed-field/roster-equipment/bond-growth/bond-roster/report-reference/sortie-roster/latest-history/detail recovery, timestamp-safe history/slot recovery, unknown-step/report/history recovery, unsupported-version rejection, and slotted fallback.'); } finally { await server.close(); } diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index c2ea90b..893c81e 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -948,6 +948,23 @@ function normalizeNonNegativeInteger(value: unknown) { return Number.isFinite(numeric) && numeric > 0 ? numeric : 0; } +function normalizeCharacterLevel(value: unknown) { + return Math.min(99, Math.max(1, normalizeNonNegativeInteger(value))); +} + +function normalizeCharacterExpForLevel(level: number, value: unknown) { + const exp = normalizeNonNegativeInteger(value); + return Math.min(exp, level >= 99 ? 100 : 99); +} + +function normalizeBondLevel(value: unknown) { + return Math.min(100, normalizeNonNegativeInteger(value)); +} + +function normalizeBondExp(value: unknown) { + return Math.min(99, normalizeNonNegativeInteger(value)); +} + function normalizeCampaignTimestamp(value: unknown) { if (typeof value !== 'string' || value.length === 0) { return undefined; @@ -1119,11 +1136,12 @@ function normalizeCampaignUnitProgressSnapshot(value: unknown): CampaignUnitProg const hp = normalizeNonNegativeInteger(value.hp); const maxHp = Math.max(hp, normalizeNonNegativeInteger(value.maxHp)); + const level = normalizeCharacterLevel(value.level); return { unitId, name, - level: Math.max(1, normalizeNonNegativeInteger(value.level)), - exp: normalizeNonNegativeInteger(value.exp), + level, + exp: normalizeCharacterExpForLevel(level, value.exp), hp, maxHp: Math.max(1, maxHp), equipment: normalizeEquipmentSet(value.equipment) @@ -1141,11 +1159,12 @@ function normalizeCampaignBondProgressSnapshot(value: unknown): CampaignBondProg return undefined; } + const level = normalizeBondLevel(value.level); return { id, title, - level: normalizeNonNegativeInteger(value.level), - exp: normalizeNonNegativeInteger(value.exp), + level, + exp: normalizeBondExp(value.exp), battleExp: normalizeNonNegativeInteger(value.battleExp) }; } @@ -1162,6 +1181,7 @@ function normalizeReserveTrainingSnapshot(value: unknown): CampaignReserveTraini return undefined; } + const level = normalizeCharacterLevel(value.level); return { unitId, name, @@ -1170,8 +1190,8 @@ function normalizeReserveTrainingSnapshot(value: unknown): CampaignReserveTraini bondExpGained: normalizeNonNegativeInteger(value.bondExpGained), focusId: normalizeReserveTrainingFocusId(typeof value.focusId === 'string' ? value.focusId : undefined), ...(focusLabel ? { focusLabel } : {}), - level: normalizeNonNegativeInteger(value.level), - exp: normalizeNonNegativeInteger(value.exp) + level, + exp: normalizeCharacterExpForLevel(level, value.exp) }; } @@ -1274,14 +1294,15 @@ function normalizeUnitDataSnapshot(value: unknown): UnitData | undefined { const stats = recordOrEmpty(value.stats); const hp = normalizeNonNegativeInteger(value.hp); const maxHp = Math.max(hp, normalizeNonNegativeInteger(value.maxHp)); + const level = normalizeCharacterLevel(value.level); return { id, name, faction: value.faction, className: normalizeDisplayString(value.className), classKey: normalizeUnitClassKey(value.classKey), - level: Math.max(1, normalizeNonNegativeInteger(value.level)), - exp: normalizeNonNegativeInteger(value.exp), + level, + exp: normalizeCharacterExpForLevel(level, value.exp), hp, maxHp: Math.max(1, maxHp), attack: normalizeNonNegativeInteger(value.attack), @@ -1311,10 +1332,11 @@ function normalizeEquipmentSet(value: unknown): UnitData['equipment'] { typeof state.itemId === 'string' && itemCatalog[state.itemId]?.slot === slot ? state.itemId : defaultEquipmentBySlot[slot]; + const level = normalizeEquipmentLevel(state.level); equipment[slot] = { itemId, - level: normalizeEquipmentLevel(state.level), - exp: normalizeNonNegativeInteger(state.exp) + level, + exp: normalizeEquipmentExpForLevel(level, state.exp) }; return equipment; }, {} as UnitData['equipment']); @@ -1325,6 +1347,12 @@ function normalizeEquipmentLevel(value: unknown) { return Math.min(9, Math.max(1, level)); } +function normalizeEquipmentExpForLevel(level: number, value: unknown) { + const exp = normalizeNonNegativeInteger(value); + const next = equipmentExpToNext(level); + return Math.min(exp, level >= 9 ? next : Math.max(0, next - 1)); +} + function normalizeCampMvpSnapshot(value: unknown): CampMvpSnapshot | undefined { if (!isPlainObject(value)) { return undefined; @@ -1356,13 +1384,14 @@ function normalizeCampBondSnapshot(value: unknown): CampBondSnapshot | undefined return undefined; } + const level = normalizeBondLevel(value.level); return { ...(value as CampBondSnapshot), id, title, unitIds: [unitIds[0], unitIds[1]] as [string, string], - level: normalizeNonNegativeInteger(value.level), - exp: normalizeNonNegativeInteger(value.exp), + level, + exp: normalizeBondExp(value.exp), battleExp: normalizeNonNegativeInteger(value.battleExp) }; }