Cap campaign battle history snapshots

This commit is contained in:
2026-07-05 12:04:23 +09:00
parent 20def5e151
commit d48726c21c
2 changed files with 215 additions and 94 deletions

View File

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

View File

@@ -269,6 +269,11 @@ const campaignInventoryCaps: Record<string, number> = {
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<T>(value: unknown, normalizer: (entry: unknown) => T | undefined, maxEntries: number): T[] {
const normalized: T[] = [];
for (const entry of arrayOrEmpty<unknown>(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<unknown>(settlement.objectives)
.map(normalizeBattleObjectiveSnapshot)
.filter((objective): objective is BattleObjectiveSnapshot => Boolean(objective)),
units: arrayOrEmpty<unknown>(settlement.units)
.map(normalizeCampaignUnitProgressSnapshot)
.filter((unit): unit is CampaignUnitProgressSnapshot => Boolean(unit)),
bonds: arrayOrEmpty<unknown>(settlement.bonds)
.map(normalizeCampaignBondProgressSnapshot)
.filter((bond): bond is CampaignBondProgressSnapshot => Boolean(bond)),
reserveTraining: arrayOrEmpty<unknown>(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<unknown>(report.objectives)
.map(normalizeBattleObjectiveSnapshot)
.filter((objective): objective is BattleObjectiveSnapshot => Boolean(objective)),
units: arrayOrEmpty<unknown>(report.units)
.map(normalizeUnitDataSnapshot)
.filter((unit): unit is UnitData => Boolean(unit)),
bonds: arrayOrEmpty<unknown>(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<unknown>(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<unknown>(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<unknown>(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
};
}