feat: add source-owned combat mechanic programs
This commit is contained in:
+98
-25
@@ -3,23 +3,49 @@ export type GearSelection = { itemId: string; level: number; brandId: string; ta
|
||||
export type WeaponSelection = { itemId: string; level: number; tier: number; damage: number; firepower: number; toughness: number; engineering: number; talent1Id: string; talent2Id: string; rolls: [Roll, Roll] };
|
||||
export type ChipSelection = { setId: string; quality: 'Superior' | 'High-end'; primary: Roll; secondary: [Roll, Roll] };
|
||||
export type ProtocolSelection = { itemId: string; level: number; talentUpgrades: number };
|
||||
export type CombatProfile = { encounterSeconds: number; hitRate: number; headshotRate: number; firingActivity: number };
|
||||
export type CombatProfile = {
|
||||
encounterSeconds: number;
|
||||
hitRate: number;
|
||||
headshotRate: number;
|
||||
shotsPerBurst: number;
|
||||
burstPauseSeconds: number;
|
||||
sameTargetSeconds: number;
|
||||
distinctTargetsPerMinute: number;
|
||||
killsPerMinute: number;
|
||||
headshotKillRate: number;
|
||||
damageTakenPerMinute: number;
|
||||
swapsPerMinute: number;
|
||||
targetDistanceMeters: number;
|
||||
healthPercent: number;
|
||||
extraHealthActive: boolean;
|
||||
};
|
||||
export type CalculationMode = 'maximum' | 'custom';
|
||||
export type EffectMode = 'off' | 'peak' | 'encounter';
|
||||
export type ConditionalEffectSelection = { mode: EffectMode; overrides?: Partial<CombatProfile> };
|
||||
export type ConditionalEffectSelection = {
|
||||
mode: EffectMode;
|
||||
overrides?: Partial<CombatProfile>;
|
||||
inputs?: Record<string, number | boolean>;
|
||||
};
|
||||
export type SkillActivationSelection = {
|
||||
mode: 'off' | 'on_cooldown' | 'custom_per_minute';
|
||||
usesPerMinute?: number;
|
||||
targets: number;
|
||||
};
|
||||
export type SpecializationSelection = {
|
||||
focusId: string;
|
||||
nodeRanks: Record<string, number>;
|
||||
effectStates: Record<string, ConditionalEffectSelection>;
|
||||
combatProfile: CombatProfile;
|
||||
};
|
||||
export type LoadoutDraft = {
|
||||
version: 2;
|
||||
version: 4;
|
||||
buildName: string;
|
||||
calculationMode: CalculationMode;
|
||||
gear: Record<string, GearSelection>;
|
||||
weapons: [WeaponSelection, WeaponSelection];
|
||||
chips: [ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection];
|
||||
protocol: ProtocolSelection;
|
||||
activeTalents: string[];
|
||||
conditionalEffects: Record<string, ConditionalEffectSelection>;
|
||||
combatProfile: CombatProfile;
|
||||
skillActivations: Record<string, SkillActivationSelection>;
|
||||
specialization: SpecializationSelection;
|
||||
};
|
||||
|
||||
@@ -28,20 +54,36 @@ 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: [] });
|
||||
export const blankWeapon = (): WeaponSelection => ({ itemId: '', level: 50, tier: 0, damage: 0, firepower: 0, toughness: 0, engineering: 0, talent1Id: '', talent2Id: '', rolls: [blankRoll(), blankRoll()] });
|
||||
export const blankChip = (): ChipSelection => ({ setId: '', quality: 'Superior', primary: blankRoll(), secondary: [blankRoll(), blankRoll()] });
|
||||
export const defaultCombatProfile = (): CombatProfile => ({ encounterSeconds: 60, hitRate: 75, headshotRate: 20, firingActivity: 80 });
|
||||
export const defaultCombatProfile = (): CombatProfile => ({
|
||||
encounterSeconds: 60,
|
||||
hitRate: 75,
|
||||
headshotRate: 20,
|
||||
shotsPerBurst: 30,
|
||||
burstPauseSeconds: 0,
|
||||
sameTargetSeconds: 5,
|
||||
distinctTargetsPerMinute: 12,
|
||||
killsPerMinute: 4,
|
||||
headshotKillRate: 20,
|
||||
damageTakenPerMinute: 6,
|
||||
swapsPerMinute: 2,
|
||||
targetDistanceMeters: 15,
|
||||
healthPercent: 100,
|
||||
extraHealthActive: false
|
||||
});
|
||||
export const createDraft = (): LoadoutDraft => ({
|
||||
version: 2,
|
||||
version: 4,
|
||||
buildName: 'Untitled loadout',
|
||||
calculationMode: 'maximum',
|
||||
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
|
||||
weapons: [blankWeapon(), blankWeapon()],
|
||||
chips: [blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip()],
|
||||
protocol: { itemId: '', level: 1, talentUpgrades: 0 },
|
||||
activeTalents: [],
|
||||
conditionalEffects: {},
|
||||
combatProfile: defaultCombatProfile(),
|
||||
skillActivations: {},
|
||||
specialization: {
|
||||
focusId: '',
|
||||
nodeRanks: {},
|
||||
effectStates: {},
|
||||
combatProfile: defaultCombatProfile()
|
||||
nodeRanks: {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -88,39 +130,70 @@ const isProtocolSelection = (value: unknown): value is ProtocolSelection =>
|
||||
&& 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]));
|
||||
&& hasExactKeys(value, [
|
||||
'encounterSeconds', 'hitRate', 'headshotRate', 'shotsPerBurst', 'burstPauseSeconds',
|
||||
'sameTargetSeconds', 'distinctTargetsPerMinute', 'killsPerMinute', 'headshotKillRate', 'damageTakenPerMinute',
|
||||
'swapsPerMinute', 'targetDistanceMeters', 'healthPercent', 'extraHealthActive'
|
||||
])
|
||||
&& [
|
||||
'encounterSeconds', 'hitRate', 'headshotRate', 'shotsPerBurst', 'burstPauseSeconds',
|
||||
'sameTargetSeconds', 'distinctTargetsPerMinute', 'killsPerMinute', 'headshotKillRate', 'damageTakenPerMinute',
|
||||
'swapsPerMinute', 'targetDistanceMeters', 'healthPercent'
|
||||
].every((key) => isFiniteNumber(value[key]))
|
||||
&& typeof value.extraHealthActive === 'boolean';
|
||||
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;
|
||||
const allowed = new Set(['mode', 'overrides', 'inputs']);
|
||||
if (Object.keys(entry).some((key) => !allowed.has(key))
|
||||
|| !['off', 'peak', 'encounter'].includes(String(entry.mode))) return false;
|
||||
if (entry.inputs !== undefined && (
|
||||
!isRecord(entry.inputs)
|
||||
|| Object.values(entry.inputs).some((input) =>
|
||||
typeof input !== 'boolean' && !isFiniteNumber(input)
|
||||
)
|
||||
)) return false;
|
||||
return entry.overrides === undefined || (
|
||||
isRecord(entry.overrides)
|
||||
&& Object.entries(entry.overrides).every(([key, override]) =>
|
||||
['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity'].includes(key) && isFiniteNumber(override)
|
||||
key === 'extraHealthActive'
|
||||
? typeof override === 'boolean'
|
||||
: key in defaultCombatProfile() && isFiniteNumber(override)
|
||||
)
|
||||
);
|
||||
});
|
||||
const isSkillActivations = (value: unknown): value is Record<string, SkillActivationSelection> =>
|
||||
isRecord(value) && Object.values(value).every((entry) =>
|
||||
isRecord(entry)
|
||||
&& ['off', 'on_cooldown', 'custom_per_minute'].includes(String(entry.mode))
|
||||
&& isFiniteNumber(entry.targets)
|
||||
&& (entry.usesPerMinute === undefined || isFiniteNumber(entry.usesPerMinute))
|
||||
&& Object.keys(entry).every((key) => ['mode', 'usesPerMinute', 'targets'].includes(key))
|
||||
);
|
||||
|
||||
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;
|
||||
|| !hasExactKeys(value, [
|
||||
'version', 'buildName', 'calculationMode', 'gear', 'weapons', 'chips', 'protocol',
|
||||
'conditionalEffects', 'combatProfile', 'skillActivations', 'specialization'
|
||||
])
|
||||
|| value.version !== 4
|
||||
|| typeof value.buildName !== 'string'
|
||||
|| !['maximum', 'custom'].includes(String(value.calculationMode))) 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 (!isProtocolSelection(value.protocol)
|
||||
|| !isEffectStates(value.conditionalEffects)
|
||||
|| !isCombatProfile(value.combatProfile)
|
||||
|| !isSkillActivations(value.skillActivations)) return null;
|
||||
if (!isRecord(value.specialization)
|
||||
|| !hasExactKeys(value.specialization, ['focusId', 'nodeRanks', 'effectStates', 'combatProfile'])
|
||||
|| !hasExactKeys(value.specialization, ['focusId', 'nodeRanks'])
|
||||
|| typeof value.specialization.focusId !== 'string'
|
||||
|| !isNumberRecord(value.specialization.nodeRanks)
|
||||
|| !isEffectStates(value.specialization.effectStates)
|
||||
|| !isCombatProfile(value.specialization.combatProfile)) return null;
|
||||
|| !isNumberRecord(value.specialization.nodeRanks)) return null;
|
||||
return structuredClone(value) as LoadoutDraft;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user