refactor: remove retired data format support
This commit is contained in:
@@ -318,11 +318,6 @@ export function evaluateLoadoutCombat(
|
||||
else if ((focus?.abilities.length ?? 0) !== 3) missing.push('Three configured focus abilities');
|
||||
const missingChips = draft.chips.filter((chip) => !chip.setId).length;
|
||||
if (missingChips) missing.push(`${missingChips} specialization Skill Chip${missingChips === 1 ? '' : 's'}`);
|
||||
if (draft.specialization.unassignedChips.length) {
|
||||
missing.push(
|
||||
`${draft.specialization.unassignedChips.length} unresolved legacy Skill Chip${draft.specialization.unassignedChips.length === 1 ? '' : 's'}`
|
||||
);
|
||||
}
|
||||
return missing;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createDraft, parseLoadoutDraft } from './loadout.js';
|
||||
|
||||
const copyDraft = () => structuredClone(createDraft());
|
||||
|
||||
describe('parseLoadoutDraft', () => {
|
||||
it('accepts a current draft and returns an independent clone', () => {
|
||||
const draft = createDraft();
|
||||
const parsed = parseLoadoutDraft(draft);
|
||||
|
||||
expect(parsed).toEqual(draft);
|
||||
expect(parsed).not.toBe(draft);
|
||||
parsed!.gear.Holster.level = 1;
|
||||
expect(draft.gear.Holster.level).toBe(50);
|
||||
});
|
||||
|
||||
it('rejects the retired version 1 three-chip shape', () => {
|
||||
const retired = copyDraft() as unknown as Record<string, unknown>;
|
||||
retired.version = 1;
|
||||
retired.chips = (retired.chips as unknown[]).slice(0, 3);
|
||||
|
||||
expect(parseLoadoutDraft(retired)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a version 2 draft that does not contain exactly nine chips', () => {
|
||||
const draft = copyDraft();
|
||||
draft.chips.pop();
|
||||
|
||||
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a specialization missing a required current field', () => {
|
||||
const draft = copyDraft() as unknown as { specialization: Record<string, unknown> };
|
||||
delete draft.specialization.combatProfile;
|
||||
|
||||
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a specialization that carries retired unassigned chips', () => {
|
||||
const draft = copyDraft() as unknown as { specialization: Record<string, unknown> };
|
||||
draft.specialization.unassignedChips = [];
|
||||
|
||||
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects malformed nested equipment selections', () => {
|
||||
const draft = copyDraft();
|
||||
draft.weapons[0].rolls[1] = { statId: 'weapon-damage', value: Number.NaN };
|
||||
|
||||
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||
});
|
||||
});
|
||||
+78
-63
@@ -11,7 +11,6 @@ export type SpecializationSelection = {
|
||||
nodeRanks: Record<string, number>;
|
||||
effectStates: Record<string, ConditionalEffectSelection>;
|
||||
combatProfile: CombatProfile;
|
||||
unassignedChips: ChipSelection[];
|
||||
};
|
||||
export type LoadoutDraft = {
|
||||
version: 2;
|
||||
@@ -24,11 +23,6 @@ export type LoadoutDraft = {
|
||||
specialization: SpecializationSelection;
|
||||
};
|
||||
|
||||
type LegacyLoadoutDraft = Omit<LoadoutDraft, 'version' | 'chips' | 'specialization'> & {
|
||||
version: 1;
|
||||
chips: [ChipSelection, ChipSelection, ChipSelection];
|
||||
};
|
||||
|
||||
export const gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'] as const;
|
||||
export const blankRoll = (): Roll => ({ statId: '', value: 0 });
|
||||
export const blankGear = (): GearSelection => ({ itemId: '', level: 50, brandId: '', talentId: '', health: 0, armor: 0, firepower: 0, toughness: 0, engineering: 0, rolls: [blankRoll(), blankRoll()], augmentSlots: [] });
|
||||
@@ -47,66 +41,87 @@ export const createDraft = (): LoadoutDraft => ({
|
||||
focusId: '',
|
||||
nodeRanks: {},
|
||||
effectStates: {},
|
||||
combatProfile: defaultCombatProfile(),
|
||||
unassignedChips: []
|
||||
combatProfile: defaultCombatProfile()
|
||||
}
|
||||
});
|
||||
|
||||
export function migrateLoadoutDraft(value: unknown): LoadoutDraft | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const draft = value as {
|
||||
version?: number;
|
||||
buildName?: string;
|
||||
gear?: Record<string, GearSelection>;
|
||||
weapons?: WeaponSelection[];
|
||||
chips?: ChipSelection[];
|
||||
protocol?: ProtocolSelection;
|
||||
activeTalents?: string[];
|
||||
specialization?: SpecializationSelection;
|
||||
};
|
||||
if (typeof draft.buildName !== 'string' || !draft.gear || !Array.isArray(draft.weapons)
|
||||
|| draft.weapons.length !== 2 || !draft.protocol || !Array.isArray(draft.chips)) return null;
|
||||
if (draft.version === 1 && draft.chips.length === 3) {
|
||||
const migrated = createDraft();
|
||||
return {
|
||||
...migrated,
|
||||
...draft,
|
||||
version: 2,
|
||||
gear: draft.gear,
|
||||
weapons: draft.weapons as [WeaponSelection, WeaponSelection],
|
||||
chips: migrated.chips,
|
||||
protocol: draft.protocol,
|
||||
activeTalents: draft.activeTalents ?? [],
|
||||
specialization: {
|
||||
...migrated.specialization,
|
||||
unassignedChips: draft.chips.map((chip) => structuredClone(chip))
|
||||
}
|
||||
};
|
||||
}
|
||||
if (draft.version !== 2) return null;
|
||||
const fresh = createDraft();
|
||||
const chips = [...draft.chips.slice(0, 9)];
|
||||
while (chips.length < 9) chips.push(blankChip());
|
||||
return {
|
||||
...fresh,
|
||||
...draft,
|
||||
version: 2,
|
||||
gear: draft.gear,
|
||||
weapons: draft.weapons as [WeaponSelection, WeaponSelection],
|
||||
chips: chips as LoadoutDraft['chips'],
|
||||
protocol: draft.protocol,
|
||||
activeTalents: draft.activeTalents ?? [],
|
||||
specialization: {
|
||||
...fresh.specialization,
|
||||
...(draft.specialization ?? {}),
|
||||
nodeRanks: { ...(draft.specialization?.nodeRanks ?? {}) },
|
||||
effectStates: { ...(draft.specialization?.effectStates ?? {}) },
|
||||
combatProfile: { ...fresh.specialization.combatProfile, ...(draft.specialization?.combatProfile ?? {}) },
|
||||
unassignedChips: Array.isArray(draft.specialization?.unassignedChips)
|
||||
? draft.specialization.unassignedChips
|
||||
: []
|
||||
}
|
||||
};
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
|
||||
Object.keys(value).length === keys.length && keys.every((key) => key in value);
|
||||
const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
|
||||
const isNumberRecord = (value: unknown) =>
|
||||
isRecord(value) && Object.values(value).every((entry) => typeof entry === 'number' && Number.isFinite(entry));
|
||||
const isRoll = (value: unknown): value is Roll =>
|
||||
isRecord(value)
|
||||
&& hasExactKeys(value, ['statId', 'value'])
|
||||
&& typeof value.statId === 'string'
|
||||
&& isFiniteNumber(value.value);
|
||||
const isRollPair = (value: unknown): value is [Roll, Roll] =>
|
||||
Array.isArray(value) && value.length === 2 && value.every(isRoll);
|
||||
const isGearSelection = (value: unknown): value is GearSelection =>
|
||||
isRecord(value)
|
||||
&& hasExactKeys(value, ['itemId', 'level', 'brandId', 'talentId', 'health', 'armor', 'firepower', 'toughness', 'engineering', 'rolls', 'augmentSlots'])
|
||||
&& ['itemId', 'brandId', 'talentId'].every((key) => typeof value[key] === 'string')
|
||||
&& ['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key]))
|
||||
&& isRollPair(value.rolls)
|
||||
&& Array.isArray(value.augmentSlots)
|
||||
&& value.augmentSlots.every(isFiniteNumber);
|
||||
const isWeaponSelection = (value: unknown): value is WeaponSelection =>
|
||||
isRecord(value)
|
||||
&& hasExactKeys(value, ['itemId', 'level', 'tier', 'damage', 'firepower', 'toughness', 'engineering', 'talent1Id', 'talent2Id', 'rolls'])
|
||||
&& ['itemId', 'talent1Id', 'talent2Id'].every((key) => typeof value[key] === 'string')
|
||||
&& ['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key]))
|
||||
&& isRollPair(value.rolls);
|
||||
const isChipSelection = (value: unknown): value is ChipSelection =>
|
||||
isRecord(value)
|
||||
&& hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary'])
|
||||
&& typeof value.setId === 'string'
|
||||
&& (value.quality === 'Superior' || value.quality === 'High-end')
|
||||
&& isRoll(value.primary)
|
||||
&& isRollPair(value.secondary);
|
||||
const isProtocolSelection = (value: unknown): value is ProtocolSelection =>
|
||||
isRecord(value)
|
||||
&& hasExactKeys(value, ['itemId', 'level', 'talentUpgrades'])
|
||||
&& typeof value.itemId === 'string'
|
||||
&& isFiniteNumber(value.level)
|
||||
&& isFiniteNumber(value.talentUpgrades);
|
||||
const isCombatProfile = (value: unknown): value is CombatProfile =>
|
||||
isRecord(value)
|
||||
&& hasExactKeys(value, ['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity'])
|
||||
&& ['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity'].every((key) => isFiniteNumber(value[key]));
|
||||
const isEffectStates = (value: unknown): value is Record<string, ConditionalEffectSelection> =>
|
||||
isRecord(value) && Object.values(value).every((entry) => {
|
||||
if (!isRecord(entry)
|
||||
|| !hasExactKeys(entry, entry.overrides === undefined ? ['mode'] : ['mode', 'overrides'])
|
||||
|| !['off', 'peak', 'encounter'].includes(String(entry.mode))) return false;
|
||||
return entry.overrides === undefined || (
|
||||
isRecord(entry.overrides)
|
||||
&& Object.entries(entry.overrides).every(([key, override]) =>
|
||||
['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity'].includes(key) && isFiniteNumber(override)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
export function parseLoadoutDraft(value: unknown): LoadoutDraft | null {
|
||||
if (!isRecord(value)
|
||||
|| !hasExactKeys(value, ['version', 'buildName', 'gear', 'weapons', 'chips', 'protocol', 'activeTalents', 'specialization'])
|
||||
|| value.version !== 2
|
||||
|| typeof value.buildName !== 'string') return null;
|
||||
const gear = value.gear;
|
||||
if (!isRecord(gear) || Object.keys(gear).length !== gearSlots.length
|
||||
|| !gearSlots.every((slot) => isGearSelection(gear[slot]))) return null;
|
||||
if (!Array.isArray(value.weapons) || value.weapons.length !== 2 || !value.weapons.every(isWeaponSelection)) return null;
|
||||
if (!Array.isArray(value.chips) || value.chips.length !== 9 || !value.chips.every(isChipSelection)) return null;
|
||||
if (!isProtocolSelection(value.protocol) || !Array.isArray(value.activeTalents)
|
||||
|| !value.activeTalents.every((talent) => typeof talent === 'string')) return null;
|
||||
if (!isRecord(value.specialization)
|
||||
|| !hasExactKeys(value.specialization, ['focusId', 'nodeRanks', 'effectStates', 'combatProfile'])
|
||||
|| typeof value.specialization.focusId !== 'string'
|
||||
|| !isNumberRecord(value.specialization.nodeRanks)
|
||||
|| !isEffectStates(value.specialization.effectStates)
|
||||
|| !isCombatProfile(value.specialization.combatProfile)) return null;
|
||||
return structuredClone(value) as LoadoutDraft;
|
||||
}
|
||||
|
||||
export const clamp = (value: unknown, min: unknown, max: unknown) => {
|
||||
|
||||
Reference in New Issue
Block a user