From d48726c21c2b6f6f76c4f1eb26963fb04efa23cd Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 5 Jul 2026 12:04:23 +0900 Subject: [PATCH] Cap campaign battle history snapshots --- .../verify-campaign-save-normalization.mjs | 111 ++++++++-- src/game/state/campaignState.ts | 198 +++++++++++------- 2 files changed, 215 insertions(+), 94 deletions(-) diff --git a/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs index fa9212e..d7846ee 100644 --- a/scripts/verify-campaign-save-normalization.mjs +++ b/scripts/verify-campaign-save-normalization.mjs @@ -452,8 +452,15 @@ try { defeatedEnemies: '3', totalEnemies: '5', objectives: [ - { id: 'village', label: 'Village', achieved: true, detail: 12, rewardGold: '80' }, - { id: '', label: 'Broken', achieved: true } + { id: 'village', label: 'Village', achieved: true, detail: 12, summary: 'x'.repeat(220), rewardGold: '80' }, + { id: '', label: 'Broken', achieved: true }, + ...Array.from({ length: 30 }, (_, index) => ({ + id: `bonus-${index}`, + label: `Bonus ${index}`, + achieved: false, + detail: 'Hold the ridge', + rewardGold: 10 + })) ], units: [ { @@ -473,11 +480,36 @@ try { x: '2', y: '3' }, - { id: 'broken-unit', name: 'Broken' } + { id: 'broken-unit', name: 'Broken' }, + ...Array.from({ length: 120 }, (_, index) => ({ + id: `enemy-${index}`, + name: `Enemy ${index}`, + faction: 'enemy', + className: 'Infantry', + classKey: 'infantry', + level: 1, + exp: 0, + hp: 20, + maxHp: 20, + attack: 12, + move: 4, + stats: { might: 8, intelligence: 5, leadership: 6, agility: 7, luck: 5 }, + equipment: {}, + x: index % 12, + y: Math.floor(index / 12) + })) ], bonds: [ { id: 'liu-bei__guan-yu', title: 'Oath Bond', unitIds: ['liu-bei', 'guan-yu'], level: '2', exp: '30', battleExp: '4' }, - { id: 'broken-bond', title: 'Broken Bond', unitIds: ['liu-bei'] } + { id: 'broken-bond', title: 'Broken Bond', unitIds: ['liu-bei'] }, + ...Array.from({ length: 120 }, (_, index) => ({ + id: `bond-${index}`, + title: `Bond ${index}`, + unitIds: ['liu-bei', 'guan-yu'], + level: 1, + exp: 0, + battleExp: 1 + })) ], mvp: { unitId: 'liu-bei', name: 'Liu Bei', damageDealt: '88', defeats: '2' }, itemRewards: ['Bean', 'Bean', 10, 'x'.repeat(97)], @@ -485,8 +517,16 @@ try { supplies: [' Bean ', 'Bean', 20, 'x'.repeat(97)], equipment: [' Iron Sword ', 'Iron Sword'], reputation: ['Village Thanks', ' Village Thanks '], - recruits: [{ unitId: ' guan-yu ', name: ' Guan Yu ' }, { unitId: '', name: 'Broken' }], - unlocks: [{ battleId: ' second-battle-yellow-turban-pursuit ', title: ' Next ' }, { battleId: '', title: 'Broken' }], + recruits: [ + { unitId: ' guan-yu ', name: ' Guan Yu ' }, + { unitId: '', name: 'Broken' }, + ...Array.from({ length: 140 }, (_, index) => ({ unitId: `recruit-${index}`, name: `Recruit ${index}` })) + ], + unlocks: [ + { battleId: ' second-battle-yellow-turban-pursuit ', title: ' Next ' }, + { battleId: '', title: 'Broken' }, + ...Array.from({ length: 140 }, (_, index) => ({ battleId: `battle-${index}`, title: `Battle ${index}` })) + ], note: ' Reward note ' }, completedCampDialogues: ['intro', 'intro', 7], @@ -504,11 +544,12 @@ try { malformedReport.firstBattleReport?.battleId === 'first-battle-zhuo-commandery' && malformedReport.firstBattleReport.battleTitle.length > 0 && malformedReport.firstBattleReport.turnNumber === 4 && - malformedReport.firstBattleReport.objectives.length === 1 && + malformedReport.firstBattleReport.objectives.length === 24 && malformedReport.firstBattleReport.objectives[0].rewardGold === 80 && - malformedReport.firstBattleReport.units.length === 1 && + malformedReport.firstBattleReport.objectives[0].summary.length === 180 && + malformedReport.firstBattleReport.units.length === 96 && malformedReport.firstBattleReport.units[0].level === 5 && - malformedReport.firstBattleReport.bonds.length === 1 && + malformedReport.firstBattleReport.bonds.length === 96 && malformedReport.firstBattleReport.bonds[0].battleExp === 4 && malformedReport.firstBattleReport.mvp?.damageDealt === 88 && malformedReport.firstBattleReport.itemRewards.length === 1 && @@ -516,8 +557,9 @@ try { malformedReport.firstBattleReport.campaignRewards?.supplies[0] === 'Bean' && malformedReport.firstBattleReport.campaignRewards?.equipment.length === 1 && malformedReport.firstBattleReport.campaignRewards?.reputation.length === 1 && - malformedReport.firstBattleReport.campaignRewards?.recruits.length === 1 && + malformedReport.firstBattleReport.campaignRewards?.recruits.length === 128 && malformedReport.firstBattleReport.campaignRewards?.recruits[0].unitId === 'guan-yu' && + malformedReport.firstBattleReport.campaignRewards?.unlocks.length === 128 && malformedReport.firstBattleReport.campaignRewards?.unlocks[0].battleId === 'second-battle-yellow-turban-pursuit' && malformedReport.firstBattleReport.campaignRewards?.note === 'Reward note' && malformedReport.firstBattleReport.completedCampDialogues.length === 1 && @@ -589,15 +631,38 @@ try { rewardGold: '80', targetTile: { x: '4', y: '5', radius: '2' } }, - 'broken-objective' + 'broken-objective', + ...Array.from({ length: 30 }, (_, index) => ({ + id: `history-bonus-${index}`, + label: `History Bonus ${index}`, + achieved: false, + detail: 'Hold formation', + rewardGold: 5 + })) ], units: [ { unitId: 'liu-bei', name: 'Liu Bei', level: '4', exp: '33', hp: '28', maxHp: '30', equipment: 'broken' }, - { unitId: '', name: 'Broken Unit', level: 99 } + { unitId: '', name: 'Broken Unit', level: 99 }, + ...Array.from({ length: 120 }, (_, index) => ({ + unitId: `history-unit-${index}`, + name: `History Unit ${index}`, + level: 1, + exp: 0, + hp: 12, + maxHp: 12, + equipment: {} + })) ], bonds: [ { id: 'oath-bond', title: 'Oath Bond', level: '2', exp: '22', battleExp: '4' }, - { id: '', title: 'Broken Bond' } + { id: '', title: 'Broken Bond' }, + ...Array.from({ length: 120 }, (_, index) => ({ + id: `history-bond-${index}`, + title: `History Bond ${index}`, + level: 1, + exp: 0, + battleExp: 1 + })) ], reserveTraining: [ { @@ -611,7 +676,17 @@ try { level: '5', exp: '44' }, - 'broken-training' + 'broken-training', + ...Array.from({ length: 120 }, (_, index) => ({ + unitId: `reserve-${index}`, + name: `Reserve ${index}`, + expGained: 3, + equipmentExpGained: 1, + focusId: 'balanced', + focusLabel: 'Balanced', + level: 1, + exp: 3 + })) ], completedAt: '2026-07-04T01:00:00.000Z' }, @@ -664,18 +739,18 @@ try { `Expected stale latestBattleId to recover to the latest valid settlement: ${JSON.stringify(malformedHistory)}` ); assert( - malformedHistory.battleHistory['first-battle-zhuo-commandery']?.objectives.length === 1 && + malformedHistory.battleHistory['first-battle-zhuo-commandery']?.objectives.length === 24 && malformedHistory.battleHistory['first-battle-zhuo-commandery']?.objectives[0].rewardGold === 80 && malformedHistory.battleHistory['first-battle-zhuo-commandery']?.objectives[0].targetTile?.x === 4 && malformedHistory.battleHistory['first-battle-zhuo-commandery']?.objectives[0].status === undefined && - malformedHistory.battleHistory['first-battle-zhuo-commandery']?.units.length === 1 && + malformedHistory.battleHistory['first-battle-zhuo-commandery']?.units.length === 96 && malformedHistory.battleHistory['first-battle-zhuo-commandery']?.units[0].level === 4 && malformedHistory.battleHistory['first-battle-zhuo-commandery']?.units[0].equipment.weapon.itemId === 'training-sword' && malformedHistory.battleHistory['first-battle-zhuo-commandery']?.units[0].equipment.armor.itemId === 'cloth-armor' && malformedHistory.battleHistory['first-battle-zhuo-commandery']?.units[0].equipment.accessory.itemId === 'grain-pouch' && - malformedHistory.battleHistory['first-battle-zhuo-commandery']?.bonds.length === 1 && + malformedHistory.battleHistory['first-battle-zhuo-commandery']?.bonds.length === 96 && malformedHistory.battleHistory['first-battle-zhuo-commandery']?.bonds[0].battleExp === 4 && - malformedHistory.battleHistory['first-battle-zhuo-commandery']?.reserveTraining?.length === 1 && + malformedHistory.battleHistory['first-battle-zhuo-commandery']?.reserveTraining?.length === 96 && malformedHistory.battleHistory['first-battle-zhuo-commandery']?.reserveTraining?.[0].focusId === 'balanced', `Expected nested battle history progress arrays to filter invalid entries and normalize numbers: ${JSON.stringify(malformedHistory)}` ); diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 8cb01c2..584362b 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -269,6 +269,11 @@ const campaignInventoryCaps: Record = { const maxCampaignStringListEntries = 128; const maxCampaignStringListLength = 96; const maxCampaignRewardNoteLength = 180; +const maxCampaignBattleObjectiveEntries = 24; +const maxCampaignBattleUnitEntries = 96; +const maxCampaignBattleBondEntries = 96; +const maxCampaignReserveTrainingEntries = 96; +const maxCampaignDisplayTextLength = 180; export const campaignReserveTrainingFocusDefinitions: CampaignReserveTrainingFocusDefinition[] = [ { @@ -890,6 +895,37 @@ function uniqueStrings(value: unknown): string[] { ].slice(0, maxCampaignStringListEntries); } +function normalizeKeyString(value: unknown): string { + if (typeof value !== 'string') { + return ''; + } + + const text = value.trim(); + return text.length > 0 && text.length <= maxCampaignStringListLength ? text : ''; +} + +function normalizeDisplayString(value: unknown, maxLength = maxCampaignDisplayTextLength): string { + if (typeof value !== 'string') { + return ''; + } + + return value.trim().slice(0, maxLength); +} + +function normalizeLimitedArray(value: unknown, normalizer: (entry: unknown) => T | undefined, maxEntries: number): T[] { + const normalized: T[] = []; + for (const entry of arrayOrEmpty(value)) { + const normalizedEntry = normalizer(entry); + if (normalizedEntry) { + normalized.push(normalizedEntry); + if (normalized.length >= maxEntries) { + break; + } + } + } + return normalized; +} + function normalizedRosterUnitIds(roster: UnitData[]) { return new Set( roster @@ -986,44 +1022,44 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle return { battleId, - battleTitle: settlement.battleTitle, + battleTitle: normalizeDisplayString(settlement.battleTitle), outcome: settlement.outcome, rewardGold: normalizeNonNegativeInteger(settlement.rewardGold), itemRewards: uniqueStrings(settlement.itemRewards), campaignRewards: cloneCampaignRewardSnapshot(settlement.campaignRewards), - objectives: arrayOrEmpty(settlement.objectives) - .map(normalizeBattleObjectiveSnapshot) - .filter((objective): objective is BattleObjectiveSnapshot => Boolean(objective)), - units: arrayOrEmpty(settlement.units) - .map(normalizeCampaignUnitProgressSnapshot) - .filter((unit): unit is CampaignUnitProgressSnapshot => Boolean(unit)), - bonds: arrayOrEmpty(settlement.bonds) - .map(normalizeCampaignBondProgressSnapshot) - .filter((bond): bond is CampaignBondProgressSnapshot => Boolean(bond)), - reserveTraining: arrayOrEmpty(settlement.reserveTraining) - .map(normalizeReserveTrainingSnapshot) - .filter((entry): entry is CampaignReserveTrainingSnapshot => Boolean(entry)), + objectives: normalizeLimitedArray(settlement.objectives, normalizeBattleObjectiveSnapshot, maxCampaignBattleObjectiveEntries), + units: normalizeLimitedArray(settlement.units, normalizeCampaignUnitProgressSnapshot, maxCampaignBattleUnitEntries), + bonds: normalizeLimitedArray(settlement.bonds, normalizeCampaignBondProgressSnapshot, maxCampaignBattleBondEntries), + reserveTraining: normalizeLimitedArray(settlement.reserveTraining, normalizeReserveTrainingSnapshot, maxCampaignReserveTrainingEntries), completedAt }; } function normalizeBattleObjectiveSnapshot(value: unknown): BattleObjectiveSnapshot | undefined { - if (!isPlainObject(value) || typeof value.id !== 'string' || value.id.length === 0 || typeof value.label !== 'string' || value.label.length === 0) { + if (!isPlainObject(value)) { + return undefined; + } + + const id = normalizeKeyString(value.id); + const label = normalizeDisplayString(value.label); + if (!id || !label) { return undefined; } const status = value.status === 'active' || value.status === 'done' || value.status === 'failed' ? value.status : undefined; const category = value.category === 'primary' || value.category === 'required' || value.category === 'bonus' ? value.category : undefined; const targetTile = normalizeObjectiveTargetTile(value.targetTile); + const summary = normalizeDisplayString(value.summary); + const failureReason = normalizeDisplayString(value.failureReason); return { - id: value.id, - label: value.label, + id, + label, achieved: Boolean(value.achieved), ...(status ? { status } : {}), - detail: typeof value.detail === 'string' ? value.detail : '', + detail: normalizeDisplayString(value.detail), ...(category ? { category } : {}), - ...(typeof value.summary === 'string' ? { summary: value.summary } : {}), - ...(typeof value.failureReason === 'string' ? { failureReason: value.failureReason } : {}), + ...(summary ? { summary } : {}), + ...(failureReason ? { failureReason } : {}), ...(targetTile ? { targetTile } : {}), rewardGold: normalizeNonNegativeInteger(value.rewardGold) }; @@ -1045,15 +1081,21 @@ function normalizeObjectiveTargetTile(value: unknown): BattleObjectiveSnapshot[' } function normalizeCampaignUnitProgressSnapshot(value: unknown): CampaignUnitProgressSnapshot | undefined { - if (!isPlainObject(value) || typeof value.unitId !== 'string' || value.unitId.length === 0 || typeof value.name !== 'string' || value.name.length === 0) { + if (!isPlainObject(value)) { + return undefined; + } + + const unitId = normalizeKeyString(value.unitId); + const name = normalizeDisplayString(value.name); + if (!unitId || !name) { return undefined; } const hp = normalizeNonNegativeInteger(value.hp); const maxHp = Math.max(hp, normalizeNonNegativeInteger(value.maxHp)); return { - unitId: value.unitId, - name: value.name, + unitId, + name, level: Math.max(1, normalizeNonNegativeInteger(value.level)), exp: normalizeNonNegativeInteger(value.exp), hp, @@ -1063,13 +1105,19 @@ function normalizeCampaignUnitProgressSnapshot(value: unknown): CampaignUnitProg } function normalizeCampaignBondProgressSnapshot(value: unknown): CampaignBondProgressSnapshot | undefined { - if (!isPlainObject(value) || typeof value.id !== 'string' || value.id.length === 0 || typeof value.title !== 'string' || value.title.length === 0) { + if (!isPlainObject(value)) { + return undefined; + } + + const id = normalizeKeyString(value.id); + const title = normalizeDisplayString(value.title); + if (!id || !title) { return undefined; } return { - id: value.id, - title: value.title, + id, + title, level: normalizeNonNegativeInteger(value.level), exp: normalizeNonNegativeInteger(value.exp), battleExp: normalizeNonNegativeInteger(value.battleExp) @@ -1077,18 +1125,25 @@ function normalizeCampaignBondProgressSnapshot(value: unknown): CampaignBondProg } function normalizeReserveTrainingSnapshot(value: unknown): CampaignReserveTrainingSnapshot | undefined { - if (!isPlainObject(value) || typeof value.unitId !== 'string' || value.unitId.length === 0 || typeof value.name !== 'string' || value.name.length === 0) { + if (!isPlainObject(value)) { + return undefined; + } + + const unitId = normalizeKeyString(value.unitId); + const name = normalizeDisplayString(value.name); + const focusLabel = normalizeDisplayString(value.focusLabel); + if (!unitId || !name) { return undefined; } return { - unitId: value.unitId, - name: value.name, + unitId, + name, expGained: normalizeNonNegativeInteger(value.expGained), equipmentExpGained: normalizeNonNegativeInteger(value.equipmentExpGained), bondExpGained: normalizeNonNegativeInteger(value.bondExpGained), focusId: normalizeReserveTrainingFocusId(typeof value.focusId === 'string' ? value.focusId : undefined), - ...(typeof value.focusLabel === 'string' ? { focusLabel: value.focusLabel } : {}), + ...(focusLabel ? { focusLabel } : {}), level: normalizeNonNegativeInteger(value.level), exp: normalizeNonNegativeInteger(value.exp) }; @@ -1153,21 +1208,15 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi return { battleId, - battleTitle, + battleTitle: normalizeDisplayString(battleTitle), outcome: report.outcome, turnNumber: normalizeNonNegativeInteger(report.turnNumber), rewardGold: normalizeNonNegativeInteger(report.rewardGold), defeatedEnemies: normalizeNonNegativeInteger(report.defeatedEnemies), totalEnemies: normalizeNonNegativeInteger(report.totalEnemies), - objectives: arrayOrEmpty(report.objectives) - .map(normalizeBattleObjectiveSnapshot) - .filter((objective): objective is BattleObjectiveSnapshot => Boolean(objective)), - units: arrayOrEmpty(report.units) - .map(normalizeUnitDataSnapshot) - .filter((unit): unit is UnitData => Boolean(unit)), - bonds: arrayOrEmpty(report.bonds) - .map(normalizeCampBondSnapshot) - .filter((bond): bond is CampBondSnapshot => Boolean(bond)), + objectives: normalizeLimitedArray(report.objectives, normalizeBattleObjectiveSnapshot, maxCampaignBattleObjectiveEntries), + units: normalizeLimitedArray(report.units, normalizeUnitDataSnapshot, maxCampaignBattleUnitEntries), + bonds: normalizeLimitedArray(report.bonds, normalizeCampBondSnapshot, maxCampaignBattleBondEntries), mvp: normalizeCampMvpSnapshot(report.mvp), itemRewards: uniqueStrings(report.itemRewards), campaignRewards: cloneCampaignRewardSnapshot( @@ -1180,14 +1229,13 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi } function normalizeUnitDataSnapshot(value: unknown): UnitData | undefined { - if ( - !isPlainObject(value) || - typeof value.id !== 'string' || - value.id.length === 0 || - typeof value.name !== 'string' || - value.name.length === 0 || - (value.faction !== 'ally' && value.faction !== 'enemy') - ) { + if (!isPlainObject(value) || (value.faction !== 'ally' && value.faction !== 'enemy')) { + return undefined; + } + + const id = normalizeKeyString(value.id); + const name = normalizeDisplayString(value.name); + if (!id || !name) { return undefined; } @@ -1195,10 +1243,10 @@ function normalizeUnitDataSnapshot(value: unknown): UnitData | undefined { const hp = normalizeNonNegativeInteger(value.hp); const maxHp = Math.max(hp, normalizeNonNegativeInteger(value.maxHp)); return { - id: value.id, - name: value.name, + id, + name, faction: value.faction, - className: typeof value.className === 'string' ? value.className : '', + className: normalizeDisplayString(value.className), classKey: normalizeUnitClassKey(value.classKey), level: Math.max(1, normalizeNonNegativeInteger(value.level)), exp: normalizeNonNegativeInteger(value.exp), @@ -1246,19 +1294,19 @@ function normalizeEquipmentLevel(value: unknown) { } function normalizeCampMvpSnapshot(value: unknown): CampMvpSnapshot | undefined { - if ( - !isPlainObject(value) || - typeof value.unitId !== 'string' || - value.unitId.length === 0 || - typeof value.name !== 'string' || - value.name.length === 0 - ) { + if (!isPlainObject(value)) { + return undefined; + } + + const unitId = normalizeKeyString(value.unitId); + const name = normalizeDisplayString(value.name); + if (!unitId || !name) { return undefined; } return { - unitId: value.unitId, - name: value.name, + unitId, + name, damageDealt: normalizeNonNegativeInteger(value.damageDealt), defeats: normalizeNonNegativeInteger(value.defeats) }; @@ -1270,20 +1318,16 @@ function normalizeCampBondSnapshot(value: unknown): CampBondSnapshot | undefined } const unitIds = arrayOrEmpty(value.unitIds).filter((unitId): unitId is string => typeof unitId === 'string' && unitId.length > 0); - if ( - typeof value.id !== 'string' || - value.id.length === 0 || - typeof value.title !== 'string' || - value.title.length === 0 || - unitIds.length < 2 - ) { + const id = normalizeKeyString(value.id); + const title = normalizeDisplayString(value.title); + if (!id || !title || unitIds.length < 2) { return undefined; } return { ...(value as CampBondSnapshot), - id: value.id, - title: value.title, + id, + title, unitIds: [unitIds[0], unitIds[1]] as [string, string], level: normalizeNonNegativeInteger(value.level), exp: normalizeNonNegativeInteger(value.exp), @@ -1522,8 +1566,8 @@ function cloneCampaignState(state: CampaignState): CampaignState { function cloneReport(report: FirstBattleReport): FirstBattleReport { const cloned = JSON.parse(JSON.stringify(report)) as FirstBattleReport; - cloned.completedCampDialogues = [...new Set(cloned.completedCampDialogues ?? [])]; - cloned.completedCampVisits = [...new Set(cloned.completedCampVisits ?? [])]; + cloned.completedCampDialogues = uniqueStrings(cloned.completedCampDialogues); + cloned.completedCampVisits = uniqueStrings(cloned.completedCampVisits); if (cloned.campaignRewards) { cloned.campaignRewards = cloneCampaignRewardSnapshot(cloned.campaignRewards); } @@ -1544,17 +1588,19 @@ function cloneCampaignRewardSnapshot(rewards?: CampaignRewardSnapshot): Campaign recruits: arrayOrEmpty(rewardRecord.recruits) .filter(isPlainObject) .map((recruit) => ({ - unitId: typeof recruit.unitId === 'string' ? recruit.unitId.trim() : '', - name: typeof recruit.name === 'string' ? recruit.name.trim() : '' + unitId: normalizeKeyString(recruit.unitId), + name: normalizeDisplayString(recruit.name) })) - .filter((recruit) => recruit.unitId && recruit.name), + .filter((recruit) => recruit.unitId && recruit.name) + .slice(0, maxCampaignStringListEntries), unlocks: arrayOrEmpty(rewardRecord.unlocks) .filter(isPlainObject) .map((unlock) => ({ - battleId: typeof unlock.battleId === 'string' ? unlock.battleId.trim() : '', - title: typeof unlock.title === 'string' ? unlock.title.trim() : '' + battleId: normalizeKeyString(unlock.battleId), + title: normalizeDisplayString(unlock.title) })) - .filter((unlock) => unlock.battleId && unlock.title), + .filter((unlock) => unlock.battleId && unlock.title) + .slice(0, maxCampaignStringListEntries), note: note.length > 0 && note.length <= maxCampaignRewardNoteLength ? note : undefined }; }