feat: model specialization combat effects
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
import {
|
||||
calculateCombat,
|
||||
type AttributeBinding,
|
||||
type CalculationStage
|
||||
} from '$lib/calculations/combat';
|
||||
import { gearSlots, scaled, type LoadoutDraft } from '$lib/builder/loadout';
|
||||
import type { WeaponCalculationResult } from '$lib/components/builder/WeaponCalculationResult.svelte';
|
||||
|
||||
type Catalog = {
|
||||
attributes: any[];
|
||||
gear: any[];
|
||||
gearAugments: any[];
|
||||
brands: any[];
|
||||
weapons: any[];
|
||||
talents: any[];
|
||||
chipSets: any[];
|
||||
protocols: any[];
|
||||
specializations: any[];
|
||||
calculation: { revision: any | null; stages: any[]; profiles: any[] };
|
||||
};
|
||||
|
||||
export type LoadoutSummary = {
|
||||
stats: Array<{ id: string; value: number; sourceCount: number }>;
|
||||
activeBonuses: any[];
|
||||
equippedTalents: any[];
|
||||
};
|
||||
|
||||
const coreKeys = ['firepower', 'toughness', 'engineering'] as const;
|
||||
|
||||
function selectedFocus(draft: LoadoutDraft, catalog: Catalog) {
|
||||
return catalog.specializations
|
||||
.flatMap((specialization) => specialization.focuses.map((focus: any) => ({ ...focus, specialization })))
|
||||
.find((focus: any) => focus.id === draft.specialization.focusId);
|
||||
}
|
||||
|
||||
function selectedNodeRanks(draft: LoadoutDraft, catalog: Catalog) {
|
||||
const focus = selectedFocus(draft, catalog);
|
||||
return focus?.nodes.flatMap((node: any) => {
|
||||
const selectedRank = Number(draft.specialization.nodeRanks[node.id] ?? 0);
|
||||
return selectedRank
|
||||
? [{ node, rank: node.ranks.find((entry: any) => Number(entry.rank) === selectedRank) }]
|
||||
: [];
|
||||
}) ?? [];
|
||||
}
|
||||
|
||||
function attribute(catalog: Catalog, id: string) {
|
||||
return catalog.attributes.find((item) => item.id === id);
|
||||
}
|
||||
|
||||
function unitValue(catalog: Catalog, value: unknown, statId: string) {
|
||||
const numeric = Number(value);
|
||||
const rendered = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/\.?0+$/, '');
|
||||
const unit = attribute(catalog, statId)?.unit ?? '';
|
||||
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
|
||||
}
|
||||
|
||||
function talentTier(catalog: Catalog, talentId: string, tier: number) {
|
||||
const item = catalog.talents.find((entry) => entry.id === talentId);
|
||||
return item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ?? item?.tiers[0];
|
||||
}
|
||||
|
||||
function talentDescription(catalog: Catalog, talentId: string, tier: number) {
|
||||
const item = catalog.talents.find((entry) => entry.id === talentId);
|
||||
const configured = talentTier(catalog, talentId, tier);
|
||||
if (!item || !configured) return '';
|
||||
const template = String(item.template || item.name);
|
||||
return template.replace(/\{(\d+)\}/g, (placeholder, raw) => {
|
||||
const effect = configured.effects[Number(raw) - 1];
|
||||
return effect ? unitValue(catalog, effect.value, effect.stat_attribute_id) : placeholder;
|
||||
});
|
||||
}
|
||||
|
||||
export function summarizeLoadout(draft: LoadoutDraft, catalog: Catalog): LoadoutSummary {
|
||||
const values = new Map<string, number>();
|
||||
const sources = new Map<string, Set<string>>();
|
||||
const add = (statId: string, value: unknown, source: string) => {
|
||||
if (!statId || !Number.isFinite(Number(value))) return;
|
||||
const stat = attribute(catalog, statId);
|
||||
const current = values.get(statId) ?? 0;
|
||||
const incoming = Number(value);
|
||||
const combined = stat?.calculation_type === 'Multiplicative' && stat?.unit === '%'
|
||||
? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100
|
||||
: current + incoming;
|
||||
values.set(
|
||||
statId,
|
||||
stat?.cap_value === null || stat?.cap_value === undefined
|
||||
? combined
|
||||
: Math.min(combined, Number(stat.cap_value))
|
||||
);
|
||||
if (!sources.has(statId)) sources.set(statId, new Set());
|
||||
sources.get(statId)!.add(source);
|
||||
};
|
||||
const byName = (name: string) =>
|
||||
catalog.attributes.find((item) => item.name.toLocaleLowerCase() === name.toLocaleLowerCase())?.id ?? '';
|
||||
const brandCounts = new Map<string, number>();
|
||||
const activeBonuses: any[] = [];
|
||||
const equippedTalents: any[] = [];
|
||||
|
||||
for (const slot of gearSlots) {
|
||||
const selection = draft.gear[slot];
|
||||
const item = catalog.gear.find((entry) => entry.id === selection.itemId);
|
||||
if (!item) continue;
|
||||
const enabledAugments = catalog.gearAugments.filter(
|
||||
(entry) => entry.gear_slot === slot && selection.augmentSlots.includes(Number(entry.augment_slot))
|
||||
);
|
||||
const basePercentage = enabledAugments
|
||||
.filter((entry) => entry.effect_type === 'Base Percentage')
|
||||
.reduce((total, entry) => total + Number(entry.value), 0);
|
||||
add(byName('Health'), selection.health * (1 + basePercentage / 100), `${slot} base`);
|
||||
if (item.base_armor_min !== null) add(byName('Armor'), selection.armor * (1 + basePercentage / 100), `${slot} base`);
|
||||
for (const roll of selection.rolls) add(roll.statId, roll.value, `${slot} roll`);
|
||||
for (const key of coreKeys) add(byName(key), selection[key], `${slot} core`);
|
||||
if (selection.brandId) brandCounts.set(selection.brandId, (brandCounts.get(selection.brandId) ?? 0) + 1);
|
||||
for (const augment of enabledAugments.filter((entry) => entry.effect_type === 'Custom')) {
|
||||
add(augment.stat_attribute_id, augment.value, `${slot} augment ${augment.augment_slot}`);
|
||||
}
|
||||
if (selection.talentId) {
|
||||
equippedTalents.push({
|
||||
key: `gear:${slot}:${selection.talentId}`,
|
||||
id: selection.talentId,
|
||||
tier: Number(item.tier),
|
||||
source: slot,
|
||||
description: talentDescription(catalog, selection.talentId, Number(item.tier))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [brandId, count] of brandCounts) {
|
||||
const set = catalog.brands.find((entry) => entry.id === brandId);
|
||||
for (const effect of set?.effects.filter((entry: any) => Number(entry.pieces_required) <= count) ?? []) {
|
||||
add(effect.stat_attribute_id, effect.value, `${set.name} ${effect.pieces_required}-piece`);
|
||||
activeBonuses.push({
|
||||
name: set.name,
|
||||
pieces: effect.pieces_required,
|
||||
count,
|
||||
statId: effect.stat_attribute_id,
|
||||
value: effect.value
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
const selection = draft.weapons[index];
|
||||
const item = catalog.weapons.find((entry) => entry.id === selection.itemId);
|
||||
if (!item) continue;
|
||||
for (const name of ['Firepower', 'Toughness', 'Engineering'] as const) {
|
||||
add(byName(name), selection[name.toLowerCase() as 'firepower'], `${index ? 'Secondary' : 'Primary'} weapon`);
|
||||
}
|
||||
for (const bonus of item.baseBonuses ?? []) add(bonus.stat_attribute_id, bonus.value, `${item.name} base bonus`);
|
||||
for (const roll of selection.rolls) add(roll.statId, roll.value, `${index ? 'Secondary' : 'Primary'} weapon roll`);
|
||||
for (const talentId of [selection.talent1Id, selection.talent2Id, item.exotic_talent_id].filter(Boolean)) {
|
||||
equippedTalents.push({
|
||||
key: `weapon:${index}:${talentId}`,
|
||||
id: talentId,
|
||||
tier: selection.tier,
|
||||
source: index ? 'Secondary weapon' : 'Primary weapon',
|
||||
description: talentDescription(catalog, talentId, selection.tier)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const chipGroups = new Map<string, { count: number; highEnd: number; item: any }>();
|
||||
for (let index = 0; index < 9; index += 1) {
|
||||
const selection = draft.chips[index];
|
||||
const item = catalog.chipSets.find((entry) => entry.id === selection.setId);
|
||||
if (!item) continue;
|
||||
add(selection.primary.statId, selection.primary.value, `Skill Chip ${index + 1} primary`);
|
||||
for (const roll of selection.secondary) add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
|
||||
const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item };
|
||||
group.count += 1;
|
||||
if (selection.quality === 'High-end') group.highEnd += 1;
|
||||
chipGroups.set(item.id, group);
|
||||
}
|
||||
for (const group of chipGroups.values()) {
|
||||
if (group.count >= 2 && group.item.twoPiece) {
|
||||
const value = scaled(group.item.twoPiece.min_value, group.item.twoPiece.max_value, group.highEnd, 3);
|
||||
add(group.item.twoPiece.stat_attribute_id, value, `${group.item.name} 2-piece`);
|
||||
activeBonuses.push({
|
||||
name: group.item.name,
|
||||
pieces: 2,
|
||||
count: group.count,
|
||||
statId: group.item.twoPiece.stat_attribute_id,
|
||||
value
|
||||
});
|
||||
}
|
||||
if (group.count === 3 && group.item.talent) {
|
||||
equippedTalents.push({
|
||||
key: `chips:${group.item.id}`,
|
||||
id: '',
|
||||
custom: group.item.talent,
|
||||
tier: group.highEnd,
|
||||
source: '3 Skill Chips',
|
||||
description: group.item.talent.description_template.replace(/\{(\d+)\}/g, (placeholder: string, raw: string) => {
|
||||
const effect = group.item.talent.effects[Number(raw) - 1];
|
||||
return effect
|
||||
? unitValue(catalog, scaled(effect.min_value, effect.max_value, group.highEnd, 3), effect.stat_attribute_id)
|
||||
: placeholder;
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const { node, rank } of selectedNodeRanks(draft, catalog)) {
|
||||
for (const effect of rank?.effects ?? []) {
|
||||
if (effect.effect_type === 'Attribute') add(effect.stat_attribute_id, effect.value, `${node.name} rank ${rank.rank}`);
|
||||
}
|
||||
}
|
||||
|
||||
const protocol = catalog.protocols.find((entry) => entry.id === draft.protocol.itemId);
|
||||
if (protocol) {
|
||||
if (protocol.primaryEffect) {
|
||||
add(
|
||||
protocol.primaryEffect.stat_attribute_id,
|
||||
Number(protocol.primaryEffect.base_value) * (1 + (draft.protocol.level - 1) / 49),
|
||||
`${protocol.name} primary`
|
||||
);
|
||||
}
|
||||
for (const effect of protocol.secondaryEffects ?? []) {
|
||||
add(effect.stat_attribute_id, effect.value, `${protocol.name} secondary`);
|
||||
}
|
||||
if (protocol.talent) {
|
||||
equippedTalents.push({
|
||||
key: `protocol:${protocol.id}`,
|
||||
id: '',
|
||||
custom: protocol.talent,
|
||||
tier: draft.protocol.talentUpgrades,
|
||||
source: 'OS Protocol',
|
||||
description: protocol.talent.description_template.replace(/\{(\d+)\}/g, (placeholder: string, raw: string) => {
|
||||
const effect = protocol.talent.effects[Number(raw) - 1];
|
||||
return effect
|
||||
? unitValue(
|
||||
catalog,
|
||||
Number(effect.base_value) * (1 + draft.protocol.talentUpgrades / 5),
|
||||
effect.stat_attribute_id
|
||||
)
|
||||
: placeholder;
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const equipped of equippedTalents) {
|
||||
if (!draft.activeTalents.includes(equipped.key)) continue;
|
||||
const effects = equipped.custom?.effects ?? talentTier(catalog, equipped.id, equipped.tier)?.effects ?? [];
|
||||
for (const effect of effects) {
|
||||
const value = equipped.key.startsWith('chips:')
|
||||
? scaled(effect.min_value, effect.max_value, equipped.tier, 3)
|
||||
: equipped.key.startsWith('protocol:')
|
||||
? Number(effect.base_value) * (1 + equipped.tier / 5)
|
||||
: effect.value;
|
||||
add(effect.stat_attribute_id, value, `${equipped.source} talent`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stats: [...values]
|
||||
.map(([id, value]) => ({ id, value, sourceCount: sources.get(id)?.size ?? 0 }))
|
||||
.sort((left, right) =>
|
||||
(attribute(catalog, left.id)?.category ?? '').localeCompare(attribute(catalog, right.id)?.category ?? '')
|
||||
|| (attribute(catalog, left.id)?.name ?? '').localeCompare(attribute(catalog, right.id)?.name ?? '')
|
||||
),
|
||||
activeBonuses,
|
||||
equippedTalents
|
||||
};
|
||||
}
|
||||
|
||||
function calculationStage(catalog: Catalog, stageKey: string): CalculationStage | null {
|
||||
if (['flat_damage', 'weapon_damage', 'critical_chance', 'critical_damage', 'headshot_chance', 'headshot_damage', 'final_damage'].includes(stageKey)) {
|
||||
return stageKey as CalculationStage;
|
||||
}
|
||||
const stage = catalog.calculation.stages.find((item) => item.stage_key === stageKey);
|
||||
if (!stage) return null;
|
||||
if (stage.operator === 'flat_add') return 'flat_damage';
|
||||
if (stage.operator === 'additive_percent') return 'weapon_damage';
|
||||
if (stage.operator === 'multiplicative_percent') return 'final_damage';
|
||||
if (stage.operator === 'expected_critical') return stageKey.includes('chance') ? 'critical_chance' : 'critical_damage';
|
||||
if (stage.operator === 'expected_headshot') return stageKey.includes('chance') ? 'headshot_chance' : 'headshot_damage';
|
||||
return null;
|
||||
}
|
||||
|
||||
function calculationBindings(catalog: Catalog, weapon: any): AttributeBinding[] {
|
||||
return catalog.calculation.profiles
|
||||
.filter((item) => item.weapon_category === '*' || item.weapon_category === weapon?.category)
|
||||
.flatMap((profile) => {
|
||||
const stage = calculationStage(catalog, String(profile.stage_key));
|
||||
return stage
|
||||
? [{
|
||||
attributeId: String(profile.stat_attribute_id),
|
||||
stage,
|
||||
operator: 'add' as const,
|
||||
weight: Number(profile.weight),
|
||||
softCap: profile.soft_cap,
|
||||
hardCap: profile.hard_cap,
|
||||
curveType: profile.curve_type,
|
||||
curveParameter: profile.curve_parameter
|
||||
}]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
export function evaluateLoadoutCombat(
|
||||
draft: LoadoutDraft,
|
||||
catalog: Catalog,
|
||||
summary = summarizeLoadout(draft, catalog)
|
||||
): WeaponCalculationResult[] {
|
||||
const focus = selectedFocus(draft, catalog);
|
||||
const combatEffects = selectedNodeRanks(draft, catalog).flatMap(({ node, rank }: any) =>
|
||||
(rank?.combatEffects ?? []).map((effect: any) => ({ ...effect, nodeName: node.name }))
|
||||
);
|
||||
const required = (weaponIndex: number) => {
|
||||
const missing: string[] = [];
|
||||
if (!catalog.calculation.revision) missing.push('An active ISAC calculation revision');
|
||||
else if (!catalog.calculation.profiles.length) missing.push('Published attribute calculation profiles');
|
||||
if (!draft.weapons[weaponIndex].itemId) missing.push(`${weaponIndex ? 'Secondary' : 'Primary'} weapon`);
|
||||
for (const slot of gearSlots) if (!draft.gear[slot].itemId) missing.push(`${slot} gear`);
|
||||
if (!draft.protocol.itemId) missing.push('OS Protocol');
|
||||
if (!draft.specialization.focusId) missing.push('Specialization focus');
|
||||
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;
|
||||
};
|
||||
|
||||
return [0, 1].map((weaponIndex) => {
|
||||
const selection = draft.weapons[weaponIndex];
|
||||
const weapon = catalog.weapons.find((item) => item.id === selection.itemId);
|
||||
const missing = required(weaponIndex);
|
||||
if (missing.length) {
|
||||
return {
|
||||
weaponName: weapon?.name ?? `${weaponIndex ? 'Secondary' : 'Primary'} weapon`,
|
||||
weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON',
|
||||
status: 'insufficient',
|
||||
missing,
|
||||
revisionLabel: catalog.calculation.revision
|
||||
? `RULES R${catalog.calculation.revision.version}`
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
const bindings = calculationBindings(catalog, weapon);
|
||||
const effects = combatEffects.map((effect: any) => ({
|
||||
id: String(effect.id),
|
||||
trigger: {
|
||||
hitsRequired: Number(effect.trigger_count),
|
||||
windowSeconds: Number(effect.trigger_window_seconds)
|
||||
},
|
||||
lifecycle: {
|
||||
maxStacks: Number(effect.max_stacks),
|
||||
durationSeconds: Number(effect.duration_seconds),
|
||||
stacksPerTrigger: Number(effect.stacks_per_trigger)
|
||||
},
|
||||
attributes: Object.fromEntries(effect.modifiers.map((modifier: any) => [
|
||||
String(modifier.stat_attribute_id),
|
||||
Number(modifier.value_per_stack)
|
||||
]))
|
||||
}));
|
||||
const perEffect = Object.fromEntries(combatEffects.map((effect: any) => {
|
||||
const state = draft.specialization.effectStates[effect.id] ?? { mode: 'off' };
|
||||
return [effect.id, {
|
||||
mode: state.mode,
|
||||
...(state.overrides ?? {})
|
||||
}];
|
||||
}));
|
||||
const result = calculateCombat({
|
||||
weapon: {
|
||||
baseDamage: selection.damage,
|
||||
rpm: Number(weapon?.rpm),
|
||||
magazine: Number(weapon?.magazine),
|
||||
reloadSeconds: Number(weapon?.reload_speed),
|
||||
headshotMultiplier: 2
|
||||
},
|
||||
attributes: Object.fromEntries(summary.stats.map((stat) => [stat.id, stat.value])),
|
||||
bindings,
|
||||
stageOrder: catalog.calculation.stages
|
||||
.map((stage) => calculationStage(catalog, String(stage.stage_key)))
|
||||
.filter((stage): stage is CalculationStage => stage !== null),
|
||||
stackingEffects: effects,
|
||||
assumptions: {
|
||||
mode: 'off',
|
||||
...draft.specialization.combatProfile,
|
||||
perEffect
|
||||
}
|
||||
});
|
||||
if (result.status === 'INSUFFICIENT_DATA') {
|
||||
return {
|
||||
weaponName: weapon?.name ?? `${weaponIndex ? 'Secondary' : 'Primary'} weapon`,
|
||||
weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON',
|
||||
status: 'insufficient',
|
||||
missing: result.missing.map((item) =>
|
||||
item.startsWith('binding:')
|
||||
? `Calculation profile for ${attribute(catalog, item.slice(8))?.name ?? item.slice(8)}`
|
||||
: item.replaceAll('.', ' ')
|
||||
),
|
||||
revisionLabel: `RULES R${catalog.calculation.revision.version}`
|
||||
};
|
||||
}
|
||||
return {
|
||||
weaponName: weapon.name,
|
||||
weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON',
|
||||
status: 'complete',
|
||||
bodyHit: result.hitValues?.normal,
|
||||
criticalBodyHit: result.hitValues?.critical,
|
||||
headshot: result.hitValues?.headshot,
|
||||
criticalHeadshot: result.hitValues?.criticalHeadshot,
|
||||
burstDps: result.burstDps ?? undefined,
|
||||
sustainedDps: result.sustainedDps ?? undefined,
|
||||
encounterDps: result.encounterDps ?? undefined,
|
||||
effectSummaries: result.stackSummaries.map((stack) => {
|
||||
const effect = combatEffects.find((item: any) => String(item.id) === stack.effectId);
|
||||
return {
|
||||
id: stack.effectId,
|
||||
name: String(effect?.definition?.name ?? effect?.nodeName ?? 'Conditional effect'),
|
||||
mode: stack.mode,
|
||||
averageStacks: stack.averageStacks,
|
||||
maxStacksReached: stack.maxStacksReached,
|
||||
maxStacks: Number(effect?.max_stacks ?? 0),
|
||||
stacksAtEnd: stack.stacksAtEnd,
|
||||
buildUpSeconds: stack.buildUpSeconds,
|
||||
maintained: stack.maintained
|
||||
};
|
||||
}),
|
||||
revisionLabel: `RULES R${catalog.calculation.revision.version}`
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -3,14 +3,30 @@ 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 EffectMode = 'off' | 'peak' | 'encounter';
|
||||
export type ConditionalEffectSelection = { mode: EffectMode; overrides?: Partial<CombatProfile> };
|
||||
export type SpecializationSelection = {
|
||||
focusId: string;
|
||||
nodeRanks: Record<string, number>;
|
||||
effectStates: Record<string, ConditionalEffectSelection>;
|
||||
combatProfile: CombatProfile;
|
||||
unassignedChips: ChipSelection[];
|
||||
};
|
||||
export type LoadoutDraft = {
|
||||
version: 1;
|
||||
version: 2;
|
||||
buildName: string;
|
||||
gear: Record<string, GearSelection>;
|
||||
weapons: [WeaponSelection, WeaponSelection];
|
||||
chips: [ChipSelection, ChipSelection, ChipSelection];
|
||||
chips: [ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection];
|
||||
protocol: ProtocolSelection;
|
||||
activeTalents: string[];
|
||||
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;
|
||||
@@ -18,16 +34,81 @@ 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 createDraft = (): LoadoutDraft => ({
|
||||
version: 1,
|
||||
version: 2,
|
||||
buildName: 'Untitled loadout',
|
||||
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
|
||||
weapons: [blankWeapon(), blankWeapon()],
|
||||
chips: [blankChip(), blankChip(), blankChip()],
|
||||
chips: [blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip()],
|
||||
protocol: { itemId: '', level: 1, talentUpgrades: 0 },
|
||||
activeTalents: []
|
||||
activeTalents: [],
|
||||
specialization: {
|
||||
focusId: '',
|
||||
nodeRanks: {},
|
||||
effectStates: {},
|
||||
combatProfile: defaultCombatProfile(),
|
||||
unassignedChips: []
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const clamp = (value: unknown, min: unknown, max: unknown) => {
|
||||
const low = Number(min), high = Number(max), numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return Number.isFinite(low) ? low : 0;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { calculateCombat, type CombatCalculationInput } from './combat.js';
|
||||
|
||||
const base = (overrides: Partial<CombatCalculationInput> = {}): CombatCalculationInput => ({
|
||||
weapon: { baseDamage: 100, rpm: 600, magazine: 10, reloadSeconds: 2, headshotMultiplier: 2 },
|
||||
attributes: { weapon: 20, critChance: 25, critDamage: 50, headshotChance: 0, final: 10 },
|
||||
bindings: [
|
||||
{ attributeId: 'weapon', stage: 'weapon_damage', operator: 'add' },
|
||||
{ attributeId: 'critChance', stage: 'critical_chance', operator: 'add' },
|
||||
{ attributeId: 'critDamage', stage: 'critical_damage', operator: 'add' },
|
||||
{ attributeId: 'headshotChance', stage: 'headshot_chance', operator: 'add' },
|
||||
{ attributeId: 'final', stage: 'final_damage', operator: 'add' }
|
||||
],
|
||||
assumptions: { mode: 'off', hitRate: 100, headshotRate: 0, firingActivity: 100, encounterSeconds: 20 },
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('calculateCombat', () => {
|
||||
it('calculates four hit values and reload-aware DPS', () => {
|
||||
const result = calculateCombat(base());
|
||||
expect(result.status).toBe('OK');
|
||||
expect(result.hitValues).toEqual({ normal: 132, critical: 198, headshot: 264, criticalHeadshot: 396 });
|
||||
expect(result.burstDps).toBe(1485);
|
||||
expect(result.sustainedDps).toBeCloseTo(495);
|
||||
});
|
||||
|
||||
it('applies weights, hard caps, and a configured curve safely', () => {
|
||||
const result = calculateCombat(base({
|
||||
attributes: { weapon: 100 },
|
||||
bindings: [{
|
||||
attributeId: 'weapon',
|
||||
stage: 'weapon_damage',
|
||||
operator: 'add',
|
||||
weight: 2,
|
||||
softCap: 50,
|
||||
hardCap: 80,
|
||||
curveType: 'power',
|
||||
curveParameter: 1
|
||||
}]
|
||||
}));
|
||||
expect(result.hitValues?.normal).toBe(180);
|
||||
});
|
||||
|
||||
it('keeps effects off, applies peak stacks, and honors per-effect overrides', () => {
|
||||
const stackingEffects = [{
|
||||
id: 'relentless',
|
||||
trigger: { hitsRequired: 9, windowSeconds: 3 },
|
||||
lifecycle: { maxStacks: 10, durationSeconds: 10, stacksPerTrigger: 1 },
|
||||
attributes: { weapon: 3, critDamage: 1 }
|
||||
}];
|
||||
const off = calculateCombat(base({ stackingEffects }));
|
||||
const peak = calculateCombat(base({
|
||||
stackingEffects,
|
||||
assumptions: { mode: 'off', perEffect: { relentless: { mode: 'peak' } } }
|
||||
}));
|
||||
expect(off.hitValues?.normal).toBe(132);
|
||||
expect(peak.hitValues?.normal).toBeCloseTo(165);
|
||||
expect(peak.stackSummaries[0].stacksAtEnd).toBe(10);
|
||||
});
|
||||
|
||||
it('models encounter stack ramp and refresh with the weapon firing cycle', () => {
|
||||
const result = calculateCombat(base({
|
||||
stackingEffects: [{
|
||||
id: 'relentless',
|
||||
trigger: { hitsRequired: 9, windowSeconds: 3 },
|
||||
lifecycle: { maxStacks: 10, durationSeconds: 10, stacksPerTrigger: 1 },
|
||||
attributes: { weapon: 3 }
|
||||
}],
|
||||
assumptions: { mode: 'encounter', hitRate: 100, firingActivity: 100, encounterSeconds: 30 }
|
||||
}));
|
||||
expect(result.encounterDps).not.toBeNull();
|
||||
expect(result.encounterDps!).toBeGreaterThan(495);
|
||||
expect(result.stackSummaries[0].maxStacksReached).toBe(10);
|
||||
expect(result.stackSummaries[0].maintained).toBe(true);
|
||||
});
|
||||
|
||||
it('does not trigger when expected hits cannot meet the trigger window', () => {
|
||||
const result = calculateCombat(base({
|
||||
stackingEffects: [{
|
||||
id: 'slow',
|
||||
trigger: { hitsRequired: 9, windowSeconds: 0.5 },
|
||||
lifecycle: { maxStacks: 10, durationSeconds: 10 },
|
||||
attributes: { weapon: 3 }
|
||||
}],
|
||||
assumptions: { mode: 'encounter', hitRate: 10, firingActivity: 50, encounterSeconds: 20 }
|
||||
}));
|
||||
expect(result.stackSummaries[0]).toMatchObject({ maxStacksReached: 0, averageStacks: 0, maintained: false });
|
||||
});
|
||||
|
||||
it('returns explicit requirements instead of partial results', () => {
|
||||
const result = calculateCombat(base({
|
||||
weapon: { baseDamage: 100, rpm: 0, magazine: 0, reloadSeconds: -1 },
|
||||
assumptions: { mode: 'encounter' },
|
||||
stackingEffects: [{
|
||||
id: 'need-profile',
|
||||
trigger: { hitsRequired: 1, windowSeconds: 1 },
|
||||
lifecycle: { maxStacks: 1, durationSeconds: 1 },
|
||||
attributes: {}
|
||||
}]
|
||||
}));
|
||||
expect(result).toMatchObject({ status: 'INSUFFICIENT_DATA', hitValues: null, burstDps: null });
|
||||
expect(result.missing).toEqual(expect.arrayContaining([
|
||||
'weapon.rpm',
|
||||
'weapon.magazine',
|
||||
'weapon.reloadSeconds',
|
||||
'assumptions.hitRate',
|
||||
'assumptions.firingActivity',
|
||||
'assumptions.encounterSeconds'
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,405 @@
|
||||
export type CalculationStage =
|
||||
| 'flat_damage'
|
||||
| 'weapon_damage'
|
||||
| 'critical_chance'
|
||||
| 'critical_damage'
|
||||
| 'headshot_chance'
|
||||
| 'headshot_damage'
|
||||
| 'final_damage';
|
||||
export type CalculationOperator = 'add' | 'multiply';
|
||||
export type CurveType = 'none' | 'power' | 'smoothstep';
|
||||
export type AttributeBinding = {
|
||||
attributeId: string;
|
||||
stage: CalculationStage;
|
||||
operator: CalculationOperator;
|
||||
weight?: number;
|
||||
softCap?: number | null;
|
||||
hardCap?: number | null;
|
||||
curveType?: CurveType;
|
||||
curveParameter?: number | null;
|
||||
};
|
||||
export type StatValues = Record<string, number>;
|
||||
export type WeaponCombatInput = {
|
||||
baseDamage?: number;
|
||||
rpm?: number;
|
||||
magazine?: number;
|
||||
reloadSeconds?: number;
|
||||
headshotMultiplier?: number;
|
||||
};
|
||||
export type StackingMode = 'off' | 'peak' | 'encounter';
|
||||
export type TriggerRule = { hitsRequired: number; windowSeconds: number };
|
||||
export type StackLifecycle = { maxStacks: number; durationSeconds: number; stacksPerTrigger?: number };
|
||||
export type StackingEffect = {
|
||||
id: string;
|
||||
trigger: TriggerRule;
|
||||
lifecycle: StackLifecycle;
|
||||
attributes: StatValues;
|
||||
};
|
||||
export type CombatAssumptions = {
|
||||
mode: StackingMode;
|
||||
hitRate?: number;
|
||||
headshotRate?: number;
|
||||
firingActivity?: number;
|
||||
encounterSeconds?: number;
|
||||
perEffect?: Record<string, Partial<Omit<CombatAssumptions, 'perEffect'>>>;
|
||||
};
|
||||
export type MissingRequirement =
|
||||
| 'weapon.baseDamage'
|
||||
| 'weapon.rpm'
|
||||
| 'weapon.magazine'
|
||||
| 'weapon.reloadSeconds'
|
||||
| 'assumptions.hitRate'
|
||||
| 'assumptions.firingActivity'
|
||||
| 'assumptions.encounterSeconds'
|
||||
| `binding:${string}`;
|
||||
export type DamageValues = {
|
||||
normal: number;
|
||||
critical: number;
|
||||
headshot: number;
|
||||
criticalHeadshot: number;
|
||||
};
|
||||
export type StackSummary = {
|
||||
effectId: string;
|
||||
mode: StackingMode;
|
||||
stacksAtEnd: number;
|
||||
maxStacksReached: number;
|
||||
buildUpSeconds: number | null;
|
||||
averageStacks: number;
|
||||
maintained: boolean;
|
||||
};
|
||||
export type CombatCalculation = {
|
||||
status: 'OK' | 'INSUFFICIENT_DATA';
|
||||
missing: MissingRequirement[];
|
||||
hitValues: DamageValues | null;
|
||||
burstDps: number | null;
|
||||
sustainedDps: number | null;
|
||||
encounterDps: number | null;
|
||||
stackSummaries: StackSummary[];
|
||||
};
|
||||
export type CombatCalculationInput = {
|
||||
weapon: WeaponCombatInput;
|
||||
attributes: StatValues;
|
||||
bindings: AttributeBinding[];
|
||||
stageOrder?: CalculationStage[];
|
||||
stackingEffects?: StackingEffect[];
|
||||
assumptions: CombatAssumptions;
|
||||
};
|
||||
|
||||
type ResolvedAssumptions = {
|
||||
mode: StackingMode;
|
||||
hitRate: number;
|
||||
headshotRate: number;
|
||||
firingActivity: number;
|
||||
encounterSeconds: number;
|
||||
};
|
||||
type StackState = {
|
||||
stacks: number;
|
||||
expiresAt: number;
|
||||
maxStacksReached: number;
|
||||
buildUpSeconds: number | null;
|
||||
};
|
||||
type TimedStackEffect = {
|
||||
effect: StackingEffect;
|
||||
assumptions: ResolvedAssumptions;
|
||||
events: number[];
|
||||
};
|
||||
|
||||
const EPSILON = 0.000_001;
|
||||
const finitePositive = (value: unknown) => Number.isFinite(Number(value)) && Number(value) > 0;
|
||||
const finiteNonNegative = (value: unknown) => Number.isFinite(Number(value)) && Number(value) >= 0;
|
||||
const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));
|
||||
const percent = (value: unknown, fallback = 0) => clamp(Number.isFinite(Number(value)) ? Number(value) : fallback, 0, 100) / 100;
|
||||
|
||||
function resolveAssumptions(base: CombatAssumptions, effectId: string): ResolvedAssumptions {
|
||||
const override = base.perEffect?.[effectId] ?? {};
|
||||
return {
|
||||
mode: override.mode ?? base.mode,
|
||||
hitRate: percent(override.hitRate ?? base.hitRate, 100),
|
||||
headshotRate: percent(override.headshotRate ?? base.headshotRate, 0),
|
||||
firingActivity: percent(override.firingActivity ?? base.firingActivity, 100),
|
||||
encounterSeconds: Number(override.encounterSeconds ?? base.encounterSeconds ?? 0)
|
||||
};
|
||||
}
|
||||
|
||||
function transformedValue(raw: number, binding: AttributeBinding) {
|
||||
const weight = Number.isFinite(binding.weight) ? Number(binding.weight) : 1;
|
||||
const sign = raw < 0 ? -1 : 1;
|
||||
let value = Math.abs(raw * weight);
|
||||
const softCap = finiteNonNegative(binding.softCap) ? Number(binding.softCap) : null;
|
||||
const hardCap = finiteNonNegative(binding.hardCap) ? Number(binding.hardCap) : null;
|
||||
if (softCap !== null && value > softCap && binding.curveType && binding.curveType !== 'none') {
|
||||
const parameter = Math.max(0.01, Number(binding.curveParameter ?? 1));
|
||||
const overflow = value - softCap;
|
||||
if (binding.curveType === 'power') {
|
||||
value = softCap + overflow * Math.pow(softCap / Math.max(softCap + overflow, EPSILON), parameter);
|
||||
} else {
|
||||
const ceiling = hardCap ?? Math.max(softCap * 2, softCap + 1);
|
||||
const t = clamp(overflow / Math.max(ceiling - softCap, EPSILON), 0, 1);
|
||||
value = softCap + (ceiling - softCap) * (t * t * (3 - 2 * t));
|
||||
}
|
||||
}
|
||||
if (hardCap !== null) value = Math.min(value, hardCap);
|
||||
return value * sign;
|
||||
}
|
||||
|
||||
function addAttributes(target: StatValues, incoming: StatValues, multiplier = 1) {
|
||||
for (const [id, value] of Object.entries(incoming)) {
|
||||
if (Number.isFinite(value)) target[id] = (target[id] ?? 0) + value * multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
function requiredData(input: CombatCalculationInput): MissingRequirement[] {
|
||||
const missing: MissingRequirement[] = [];
|
||||
if (!finitePositive(input.weapon.baseDamage)) missing.push('weapon.baseDamage');
|
||||
if (!finitePositive(input.weapon.rpm)) missing.push('weapon.rpm');
|
||||
if (!Number.isInteger(input.weapon.magazine) || Number(input.weapon.magazine) < 1) missing.push('weapon.magazine');
|
||||
if (!finiteNonNegative(input.weapon.reloadSeconds)) missing.push('weapon.reloadSeconds');
|
||||
const boundIds = new Set(input.bindings.map((binding) => binding.attributeId));
|
||||
for (const binding of input.bindings) {
|
||||
if (!Number.isFinite(input.attributes[binding.attributeId] ?? 0)) missing.push(`binding:${binding.attributeId}`);
|
||||
}
|
||||
if (input.assumptions.mode === 'encounter') {
|
||||
if (!finitePositive(input.assumptions.hitRate)) missing.push('assumptions.hitRate');
|
||||
if (!finitePositive(input.assumptions.firingActivity)) missing.push('assumptions.firingActivity');
|
||||
if (!finitePositive(input.assumptions.encounterSeconds)) missing.push('assumptions.encounterSeconds');
|
||||
}
|
||||
for (const effect of input.stackingEffects ?? []) {
|
||||
const assumptions = resolveAssumptions(input.assumptions, effect.id);
|
||||
if (assumptions.mode === 'encounter') {
|
||||
const override = input.assumptions.perEffect?.[effect.id];
|
||||
if (!finitePositive(override?.hitRate ?? input.assumptions.hitRate)) missing.push('assumptions.hitRate');
|
||||
if (!finitePositive(override?.firingActivity ?? input.assumptions.firingActivity)) missing.push('assumptions.firingActivity');
|
||||
if (!finitePositive(override?.encounterSeconds ?? input.assumptions.encounterSeconds)) missing.push('assumptions.encounterSeconds');
|
||||
}
|
||||
if (assumptions.mode !== 'off') for (const id of Object.keys(effect.attributes)) {
|
||||
if (!boundIds.has(id)) missing.push(`binding:${id}`);
|
||||
}
|
||||
}
|
||||
return [...new Set(missing)];
|
||||
}
|
||||
|
||||
function stageValues(attributes: StatValues, bindings: AttributeBinding[], stage: CalculationStage) {
|
||||
let additive = 0;
|
||||
let multiplicative = 1;
|
||||
for (const binding of bindings.filter((item) => item.stage === stage)) {
|
||||
const value = transformedValue(Number(attributes[binding.attributeId] ?? 0), binding);
|
||||
if (binding.operator === 'add') additive += value;
|
||||
else multiplicative *= value;
|
||||
}
|
||||
return { additive, multiplicative };
|
||||
}
|
||||
|
||||
function damageBase(
|
||||
weapon: Required<WeaponCombatInput>,
|
||||
attributes: StatValues,
|
||||
bindings: AttributeBinding[],
|
||||
stageOrder?: CalculationStage[]
|
||||
) {
|
||||
let damage = weapon.baseDamage;
|
||||
const order = stageOrder?.length
|
||||
? stageOrder
|
||||
: ['flat_damage', 'weapon_damage', 'final_damage'] satisfies CalculationStage[];
|
||||
for (const stage of order) {
|
||||
if (!['flat_damage', 'weapon_damage', 'final_damage'].includes(stage)) continue;
|
||||
const values = stageValues(attributes, bindings, stage);
|
||||
if (stage === 'flat_damage') damage = (damage + values.additive) * values.multiplicative;
|
||||
else damage *= (1 + values.additive / 100) * values.multiplicative;
|
||||
}
|
||||
return damage;
|
||||
}
|
||||
|
||||
function hitValues(
|
||||
weapon: Required<WeaponCombatInput>,
|
||||
attributes: StatValues,
|
||||
bindings: AttributeBinding[],
|
||||
stageOrder?: CalculationStage[]
|
||||
): DamageValues {
|
||||
const normal = damageBase(weapon, attributes, bindings, stageOrder);
|
||||
const critical = stageValues(attributes, bindings, 'critical_damage');
|
||||
const headshot = stageValues(attributes, bindings, 'headshot_damage');
|
||||
const criticalFactor = (1 + critical.additive / 100) * critical.multiplicative;
|
||||
const headshotFactor = weapon.headshotMultiplier * (1 + headshot.additive / 100) * headshot.multiplicative;
|
||||
return {
|
||||
normal,
|
||||
critical: normal * criticalFactor,
|
||||
headshot: normal * headshotFactor,
|
||||
criticalHeadshot: normal * criticalFactor * headshotFactor
|
||||
};
|
||||
}
|
||||
|
||||
function dpsFor(
|
||||
weapon: Required<WeaponCombatInput>,
|
||||
attributes: StatValues,
|
||||
bindings: AttributeBinding[],
|
||||
assumptions: ResolvedAssumptions,
|
||||
stageOrder?: CalculationStage[]
|
||||
) {
|
||||
const values = hitValues(weapon, attributes, bindings, stageOrder);
|
||||
const critical = stageValues(attributes, bindings, 'critical_chance');
|
||||
const boundCriticalChance = clamp(critical.additive / 100, 0, 1);
|
||||
const configuredHeadshot = stageValues(attributes, bindings, 'headshot_chance');
|
||||
const boundHeadshotChance = clamp(configuredHeadshot.additive / 100, 0, 1);
|
||||
const headshotChance = assumptions.headshotRate || boundHeadshotChance;
|
||||
const average = values.normal * (1 - boundCriticalChance) * (1 - headshotChance)
|
||||
+ values.critical * boundCriticalChance * (1 - headshotChance)
|
||||
+ values.headshot * (1 - boundCriticalChance) * headshotChance
|
||||
+ values.criticalHeadshot * boundCriticalChance * headshotChance;
|
||||
const shotsPerSecond = weapon.rpm / 60;
|
||||
return {
|
||||
values,
|
||||
burst: average * shotsPerSecond,
|
||||
sustained: average * weapon.magazine / (weapon.magazine / shotsPerSecond + weapon.reloadSeconds)
|
||||
};
|
||||
}
|
||||
|
||||
function triggerEvents(weapon: Required<WeaponCombatInput>, effect: StackingEffect, assumptions: ResolvedAssumptions) {
|
||||
if (assumptions.mode !== 'encounter' || effect.trigger.hitsRequired < 1 || effect.trigger.windowSeconds <= 0) return [];
|
||||
const shotInterval = (60 / weapon.rpm) / Math.max(assumptions.firingActivity, EPSILON);
|
||||
let magazineRemaining = weapon.magazine;
|
||||
let expectedHits = 0;
|
||||
let counterStartedAt = 0;
|
||||
const events: number[] = [];
|
||||
for (let time = 0; time <= assumptions.encounterSeconds + EPSILON;) {
|
||||
if (magazineRemaining === 0) {
|
||||
time += weapon.reloadSeconds;
|
||||
magazineRemaining = weapon.magazine;
|
||||
if (time - counterStartedAt > effect.trigger.windowSeconds) {
|
||||
expectedHits = 0;
|
||||
counterStartedAt = time;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (expectedHits === 0) counterStartedAt = time;
|
||||
expectedHits += assumptions.hitRate;
|
||||
magazineRemaining -= 1;
|
||||
if (time - counterStartedAt > effect.trigger.windowSeconds) {
|
||||
expectedHits = assumptions.hitRate;
|
||||
counterStartedAt = time;
|
||||
}
|
||||
if (expectedHits + EPSILON >= effect.trigger.hitsRequired) {
|
||||
events.push(time);
|
||||
expectedHits = 0;
|
||||
counterStartedAt = time;
|
||||
}
|
||||
time += shotInterval;
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function stateAt(effect: TimedStackEffect, time: number): StackState {
|
||||
if (effect.assumptions.mode === 'off') {
|
||||
return { stacks: 0, expiresAt: 0, maxStacksReached: 0, buildUpSeconds: null };
|
||||
}
|
||||
if (effect.assumptions.mode === 'peak') {
|
||||
return {
|
||||
stacks: effect.effect.lifecycle.maxStacks,
|
||||
expiresAt: Infinity,
|
||||
maxStacksReached: effect.effect.lifecycle.maxStacks,
|
||||
buildUpSeconds: 0
|
||||
};
|
||||
}
|
||||
const state: StackState = { stacks: 0, expiresAt: 0, maxStacksReached: 0, buildUpSeconds: null };
|
||||
for (const eventTime of effect.events) {
|
||||
if (eventTime - time > EPSILON) break;
|
||||
if (eventTime - state.expiresAt > EPSILON) state.stacks = 0;
|
||||
state.stacks = Math.min(
|
||||
effect.effect.lifecycle.maxStacks,
|
||||
state.stacks + Math.max(1, effect.effect.lifecycle.stacksPerTrigger ?? 1)
|
||||
);
|
||||
state.expiresAt = eventTime + effect.effect.lifecycle.durationSeconds;
|
||||
state.maxStacksReached = Math.max(state.maxStacksReached, state.stacks);
|
||||
if (state.stacks === effect.effect.lifecycle.maxStacks && state.buildUpSeconds === null) {
|
||||
state.buildUpSeconds = eventTime;
|
||||
}
|
||||
}
|
||||
if (time - state.expiresAt > EPSILON) state.stacks = 0;
|
||||
return state;
|
||||
}
|
||||
|
||||
function stackAttributes(base: StatValues, effects: TimedStackEffect[], time: number) {
|
||||
const result = { ...base };
|
||||
for (const effect of effects) addAttributes(result, effect.effect.attributes, stateAt(effect, time).stacks);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function calculateCombat(input: CombatCalculationInput): CombatCalculation {
|
||||
const missing = requiredData(input);
|
||||
if (missing.length) {
|
||||
return {
|
||||
status: 'INSUFFICIENT_DATA',
|
||||
missing,
|
||||
hitValues: null,
|
||||
burstDps: null,
|
||||
sustainedDps: null,
|
||||
encounterDps: null,
|
||||
stackSummaries: []
|
||||
};
|
||||
}
|
||||
const weapon: Required<WeaponCombatInput> = {
|
||||
baseDamage: Number(input.weapon.baseDamage),
|
||||
rpm: Number(input.weapon.rpm),
|
||||
magazine: Number(input.weapon.magazine),
|
||||
reloadSeconds: Number(input.weapon.reloadSeconds),
|
||||
headshotMultiplier: Number(input.weapon.headshotMultiplier ?? 2)
|
||||
};
|
||||
const effects: TimedStackEffect[] = (input.stackingEffects ?? []).map((effect) => {
|
||||
const assumptions = resolveAssumptions(input.assumptions, effect.id);
|
||||
return { effect, assumptions, events: triggerEvents(weapon, effect, assumptions) };
|
||||
});
|
||||
const globalAssumptions = resolveAssumptions(input.assumptions, '');
|
||||
const finalTime = Math.max(
|
||||
globalAssumptions.encounterSeconds,
|
||||
...effects.filter((effect) => effect.assumptions.mode === 'encounter').map((effect) => effect.assumptions.encounterSeconds)
|
||||
);
|
||||
const current = dpsFor(
|
||||
weapon,
|
||||
stackAttributes(input.attributes, effects, finalTime),
|
||||
input.bindings,
|
||||
globalAssumptions,
|
||||
input.stageOrder
|
||||
);
|
||||
let encounterDps: number | null = null;
|
||||
if (effects.some((effect) => effect.assumptions.mode === 'encounter') && finalTime > 0) {
|
||||
const steps = Math.max(30, Math.min(600, Math.ceil(finalTime * 4)));
|
||||
let total = 0;
|
||||
for (let index = 0; index < steps; index += 1) {
|
||||
const time = finalTime * (index + 0.5) / steps;
|
||||
const result = dpsFor(
|
||||
weapon,
|
||||
stackAttributes(input.attributes, effects, time),
|
||||
input.bindings,
|
||||
globalAssumptions,
|
||||
input.stageOrder
|
||||
);
|
||||
total += result.sustained * globalAssumptions.firingActivity;
|
||||
}
|
||||
encounterDps = total / steps;
|
||||
}
|
||||
return {
|
||||
status: 'OK',
|
||||
missing: [],
|
||||
hitValues: current.values,
|
||||
burstDps: current.burst,
|
||||
sustainedDps: current.sustained,
|
||||
encounterDps,
|
||||
stackSummaries: effects.map((item) => {
|
||||
const end = stateAt(item, finalTime);
|
||||
const duration = item.assumptions.mode === 'encounter' ? item.assumptions.encounterSeconds : 0;
|
||||
const samples = item.assumptions.mode === 'encounter' && duration > 0 ? 100 : 1;
|
||||
let sum = 0;
|
||||
for (let index = 0; index < samples; index += 1) {
|
||||
sum += stateAt(item, duration * (index + 0.5) / samples).stacks;
|
||||
}
|
||||
return {
|
||||
effectId: item.effect.id,
|
||||
mode: item.assumptions.mode,
|
||||
stacksAtEnd: end.stacks,
|
||||
maxStacksReached: end.maxStacksReached,
|
||||
buildUpSeconds: end.buildUpSeconds,
|
||||
averageStacks: sum / samples,
|
||||
maintained: end.maxStacksReached === item.effect.lifecycle.maxStacks
|
||||
&& (item.assumptions.mode === 'peak' || end.stacks > 0)
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -40,6 +40,7 @@
|
||||
: action === 'saveSpecialization' ? 'SPECIALIZATION'
|
||||
: action === 'saveFocus' ? 'FOCUS'
|
||||
: action === 'saveAbility' ? 'CLASS ABILITY'
|
||||
: action === 'saveCombatEffect' ? 'SHARED COMBAT EFFECT'
|
||||
: action === 'saveFocusAbilities' ? 'FOCUS ABILITY SET'
|
||||
: 'TREE NODE'
|
||||
);
|
||||
@@ -79,6 +80,12 @@
|
||||
{ label: 'COOLDOWN · SECONDS', current: specializationRecord?.cooldown_seconds, proposed: proposed.cooldownSeconds },
|
||||
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
|
||||
]
|
||||
: action === 'saveCombatEffect'
|
||||
? [
|
||||
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
|
||||
{ label: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId },
|
||||
{ label: 'DESCRIPTION TEMPLATE', current: specializationRecord?.description_template, proposed: proposed.descriptionTemplate }
|
||||
]
|
||||
: action === 'saveNode'
|
||||
? [
|
||||
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
|
||||
@@ -102,6 +109,9 @@
|
||||
const currentNodeEffects = $derived((currentSnapshot?.effects ?? []) as any[]);
|
||||
const currentNodeCosts = $derived((currentSnapshot?.costs ?? []) as any[]);
|
||||
const currentRankProfiles = $derived((currentSnapshot?.rankProfiles ?? []) as any[]);
|
||||
const calculationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
|
||||
const proposedCalculationStages = $derived((jsonValue(proposed.stagesJson) ?? []) as any[]);
|
||||
const proposedCalculationProfiles = $derived((jsonValue(proposed.profilesJson) ?? []) as any[]);
|
||||
const effectSummary = (effect: any) => {
|
||||
const target = effect.statId || effect.stat_attribute_id || effect.abilityId || effect.ability_id || effect.label || 'CUSTOM EFFECT';
|
||||
const value = effect.value === null || effect.value === undefined || effect.value === '' ? '' : `${effect.value}${effect.unit ? ` ${effect.unit}` : ''}`;
|
||||
@@ -173,7 +183,19 @@
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
{:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveFocusAbilities','saveNode'].includes(action)}
|
||||
{:else if module === 'calculations'}
|
||||
<div class="proposal-compare-grid calculation-proposal-preview">
|
||||
<article class="proposal-editor-preview before">
|
||||
<header><span>CURRENT LIVE · {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}</span><b>{calculationRecord ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
|
||||
{#if calculationRecord}<div class="proposal-field-summary"><div><span>NAME</span><strong>{shown(calculationRecord.name)}</strong></div><div><span>STATE</span><strong>{shown(calculationRecord.status)}</strong></div>{#if action === 'saveRevision'}<div><span>STAGES</span><strong>{(currentSnapshot?.stages as any[])?.length ?? 0}</strong></div><div><span>ATTRIBUTE PROFILES</span><strong>{(currentSnapshot?.profiles as any[])?.length ?? 0}</strong></div>{/if}</div>{:else}<div class="proposal-empty-state"><b>NEW {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}</b><span>No live registry record will be replaced.</span></div>{/if}
|
||||
</article>
|
||||
<article class="proposal-editor-preview after">
|
||||
<header><span>PROPOSED · {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}</span><b>AFTER</b></header>
|
||||
<div class="proposal-field-summary"><div><span>NAME</span><strong>{shown(proposed.name)}</strong></div>{#if action === 'saveRevision'}<div><span>STAGES</span><strong>{proposedCalculationStages.length}</strong></div><div><span>ATTRIBUTE PROFILES</span><strong>{proposedCalculationProfiles.length}</strong></div><div><span>BASE REVISION</span><strong>{shown(proposed.basedOnRevisionId)}</strong></div>{:else}<div><span>STATE</span><strong>{shown(proposed.status)}</strong></div><div><span>MEASURED WEAPON</span><strong>{proposed.selectedWeapon === '1' ? 'SECONDARY' : 'PRIMARY'}</strong></div><div><span>CORE OBSERVATIONS</span><strong>{['bodyHit','criticalBodyHit','headshot','criticalHeadshot','magazineSeconds','reloadSeconds'].filter((field) => proposed[field]).length} / 6</strong></div>{/if}</div>
|
||||
{#if action === 'saveRevision'}<div class="proposal-rank-preview">{#each proposedCalculationStages as stage}<section><header><span>{stage.label}</span><b>{stage.operator?.replaceAll('_',' ').toUpperCase()}</b></header><p>ORDER {stage.displayOrder} · {stage.lockedPosition?.toUpperCase() ?? 'REORDERABLE'}</p></section>{/each}</div>{/if}
|
||||
</article>
|
||||
</div>
|
||||
{:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveCombatEffect','saveFocusAbilities','saveNode'].includes(action)}
|
||||
<div class="proposal-compare-grid specialization-proposal-preview">
|
||||
<article class="proposal-editor-preview before">
|
||||
<header><span>CURRENT LIVE · {specializationKind}</span><b>{specializationRecord ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
|
||||
@@ -221,9 +243,10 @@
|
||||
<div class="proposal-rank-preview">
|
||||
{#each proposedRanks as rank}
|
||||
<section>
|
||||
<header><span>RANK {rank.rank}</span><b>{rank.effects?.length ?? 0} EFFECTS</b></header>
|
||||
<header><span>RANK {rank.rank}</span><b>{rank.effects?.length ?? 0} STATIC · {rank.combatEffects?.length ?? 0} CONDITIONAL</b></header>
|
||||
<p>{rank.description || 'NO RANK DESCRIPTION'}</p>
|
||||
{#each rank.effects ?? [] as effect}<small>{effectSummary(effect)}</small>{/each}
|
||||
{#each rank.combatEffects ?? [] as effect}<small>{effect.effectId} · {effect.triggerCount} HITS / {effect.triggerWindowSeconds}s · {effect.maxStacks} STACKS · {effect.modifiers?.length ?? 0} MODIFIERS</small>{/each}
|
||||
<small>{Object.entries(rank.costs ?? {}).filter(([, quantity]) => Number(quantity) > 0).map(([resource, quantity]) => `${resource} × ${quantity}`).join(' · ') || 'CREDITS ONLY'}</small>
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
className?: string;
|
||||
icon?: GameIconName;
|
||||
iconOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
let {
|
||||
@@ -56,6 +57,7 @@
|
||||
value={option.value}
|
||||
checked={mode === 'radio' ? value === option.value : selected.includes(option.value)}
|
||||
{required}
|
||||
disabled={option.disabled}
|
||||
onchange={(event) => change(option.value, event.currentTarget.checked)}
|
||||
/>
|
||||
<span class:icon-only={option.iconOnly} class={option.className ?? ''} title={option.iconOnly ? option.label : undefined}>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import RangeValueControl from '$lib/components/RangeValueControl.svelte';
|
||||
|
||||
export type CombatProfile = {
|
||||
encounterSeconds: number;
|
||||
hitRate: number;
|
||||
headshotRate: number;
|
||||
firingActivity: number;
|
||||
};
|
||||
|
||||
let {
|
||||
profile = $bindable<CombatProfile>(),
|
||||
disabled = false
|
||||
}: {
|
||||
profile: CombatProfile;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
function update(key: keyof CombatProfile, value: number) {
|
||||
profile = { ...profile, [key]: value };
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="combat-assumptions" aria-labelledby="combat-assumptions-title">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">COMBAT PROFILE</p>
|
||||
<h3 id="combat-assumptions-title">Encounter assumptions</h3>
|
||||
</div>
|
||||
<small>MODELED, NOT LIVE COMBAT</small>
|
||||
</div>
|
||||
<p class="supporting">These assumptions drive encounter-average calculations. Individual effects can override them when needed.</p>
|
||||
<fieldset class="assumption-grid" disabled={disabled}>
|
||||
<RangeValueControl label="Encounter duration" min={10} max={300} step={5} value={profile.encounterSeconds} onchange={(value) => update('encounterSeconds', value)} />
|
||||
<RangeValueControl label="Hit rate" min={0} max={100} value={profile.hitRate} onchange={(value) => update('hitRate', value)} />
|
||||
<RangeValueControl label="Headshot rate" min={0} max={100} value={profile.headshotRate} onchange={(value) => update('headshotRate', value)} />
|
||||
<RangeValueControl label="Firing activity" min={0} max={100} value={profile.firingActivity} onchange={(value) => update('firingActivity', value)} />
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.combat-assumptions{padding:var(--shd-gap-md);border:1px solid var(--shd-border);border-left:2px solid var(--shd-orange);background:var(--shd-surface)}
|
||||
.section-head{display:flex;align-items:start;justify-content:space-between;gap:var(--shd-gap-md)}
|
||||
.eyebrow{margin:0 0 3px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.3px}
|
||||
h3{margin:0;color:var(--shd-text);font-size:var(--shd-type-body);text-transform:uppercase;letter-spacing:.7px}
|
||||
.section-head small{padding:4px 6px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;white-space:nowrap}
|
||||
.supporting{margin:var(--shd-gap-sm) 0 var(--shd-gap-md);color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}
|
||||
.assumption-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--shd-gap-md);margin:0;padding:0;border:0}.assumption-grid:disabled{opacity:.55}
|
||||
@media(max-width:620px){.section-head{display:grid}.section-head small{width:max-content}.assumption-grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import RangeValueControl from '$lib/components/RangeValueControl.svelte';
|
||||
import type { CombatProfile } from './CombatAssumptions.svelte';
|
||||
|
||||
export type EffectMode = 'off' | 'peak' | 'encounter';
|
||||
export type ConditionalEffectState = {
|
||||
mode: EffectMode;
|
||||
overrides?: Partial<CombatProfile>;
|
||||
};
|
||||
|
||||
let {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
facts,
|
||||
appliedState,
|
||||
effectState = $bindable<ConditionalEffectState>(),
|
||||
disabled = false
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
facts: string[];
|
||||
appliedState: string;
|
||||
effectState: ConditionalEffectState;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
let advanced = $state(false);
|
||||
const modes = [
|
||||
{ value: 'off', label: 'Off' },
|
||||
{ value: 'peak', label: 'Peak' },
|
||||
{ value: 'encounter', label: 'Encounter' }
|
||||
];
|
||||
|
||||
function setMode(values: string[]) {
|
||||
effectState = { ...effectState, mode: values[0] as EffectMode };
|
||||
}
|
||||
|
||||
function updateOverride(key: keyof CombatProfile, value: number) {
|
||||
effectState = { ...effectState, overrides: { ...effectState.overrides, [key]: value } };
|
||||
}
|
||||
|
||||
function clearOverrides() {
|
||||
effectState = { ...effectState, overrides: undefined };
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class:disabled class="conditional-effect" aria-labelledby={`${id}-title`}>
|
||||
<header>
|
||||
<div>
|
||||
<p>CONDITIONAL EFFECT</p>
|
||||
<h3 id={`${id}-title`}>{name}</h3>
|
||||
{#if description}<span>{description}</span>{/if}
|
||||
</div>
|
||||
<b class:active={effectState.mode !== 'off'}>{appliedState}</b>
|
||||
</header>
|
||||
<div class="facts" aria-label={`${name} facts`}>
|
||||
{#each facts as fact}<span>{fact}</span>{/each}
|
||||
</div>
|
||||
<fieldset class="mode-control" disabled={disabled}>
|
||||
<SegmentedChoice legend={`${name} calculation mode`} name={`${id}-mode`} options={modes} value={effectState.mode} columns={3} onchange={setMode} />
|
||||
</fieldset>
|
||||
{#if effectState.mode === 'encounter'}
|
||||
<div class="advanced-head">
|
||||
<button type="button" aria-expanded={advanced} aria-controls={`${id}-overrides`} onclick={() => advanced = !advanced} disabled={disabled}>
|
||||
{advanced ? 'HIDE' : 'ADVANCED'} OVERRIDES <span aria-hidden="true">{advanced ? '−' : '+'}</span>
|
||||
</button>
|
||||
{#if effectState.overrides}<button class="clear" type="button" onclick={clearOverrides} disabled={disabled}>RESET</button>{/if}
|
||||
</div>
|
||||
{#if advanced}
|
||||
<div class="override-grid" id={`${id}-overrides`}>
|
||||
<RangeValueControl label="Hit rate" min={0} max={100} value={effectState.overrides?.hitRate ?? 100} onchange={(value) => updateOverride('hitRate', value)} />
|
||||
<RangeValueControl label="Firing activity" min={0} max={100} value={effectState.overrides?.firingActivity ?? 100} onchange={(value) => updateOverride('firingActivity', value)} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.conditional-effect{display:grid;gap:var(--shd-gap-sm);padding:var(--shd-gap-md);border:1px solid var(--shd-border);background:var(--shd-surface)}
|
||||
.conditional-effect.disabled{opacity:.55;pointer-events:none}
|
||||
header{display:flex;justify-content:space-between;align-items:start;gap:var(--shd-gap-md)}
|
||||
header p{margin:0 0 3px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.1px}
|
||||
h3{margin:0;color:var(--shd-text);font-size:var(--shd-type-body);text-transform:uppercase;letter-spacing:.7px}
|
||||
header span{display:block;margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
|
||||
header>b{flex:0 0 auto;padding:5px 7px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.8px;white-space:nowrap}
|
||||
header>b.active{border-color:#74552a;color:var(--shd-orange);background:#1d180e}
|
||||
.facts{display:flex;flex-wrap:wrap;gap:5px}.facts span{padding:4px 6px;border:1px solid var(--shd-border-soft);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:.6px}
|
||||
.mode-control{margin:0;padding:0;border:0}.advanced-head{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm);border-top:1px solid var(--shd-border-soft);padding-top:var(--shd-gap-sm)}
|
||||
button{min-height:40px;border:1px solid var(--shd-border);background:transparent;color:var(--shd-text-muted);padding:0 10px;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;cursor:pointer;transition:var(--shd-transition)}
|
||||
button:hover,button:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange)}button:active{transform:translateY(1px);filter:brightness(.88)}button:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px}.clear{border-color:#563734;color:var(--shd-danger)}.clear:hover,.clear:focus-visible{border-color:var(--shd-danger);color:var(--shd-danger)}
|
||||
.override-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--shd-gap-md);padding:var(--shd-gap-sm);border:1px solid var(--shd-border-soft);background:var(--shd-surface-raised)}
|
||||
@media(max-width:620px){header{display:grid}.override-grid{grid-template-columns:1fr}button{min-height:44px}}
|
||||
@media(prefers-reduced-motion:reduce){button{transition:none}}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
export type WeaponCalculationResult = {
|
||||
weaponName: string;
|
||||
weaponLabel: string;
|
||||
status: 'complete' | 'insufficient';
|
||||
bodyHit?: number;
|
||||
criticalBodyHit?: number;
|
||||
headshot?: number;
|
||||
criticalHeadshot?: number;
|
||||
burstDps?: number;
|
||||
sustainedDps?: number;
|
||||
encounterDps?: number;
|
||||
effectSummaries?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
mode: 'off' | 'peak' | 'encounter';
|
||||
averageStacks: number;
|
||||
maxStacksReached: number;
|
||||
maxStacks: number;
|
||||
stacksAtEnd: number;
|
||||
buildUpSeconds: number | null;
|
||||
maintained: boolean;
|
||||
}>;
|
||||
missing?: string[];
|
||||
revisionLabel?: string;
|
||||
};
|
||||
|
||||
let { result }: { result: WeaponCalculationResult } = $props();
|
||||
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });
|
||||
function format(value: number | undefined) { return value === undefined ? '—' : number.format(value); }
|
||||
</script>
|
||||
|
||||
<article class:incomplete={result.status === 'insufficient'} class="weapon-calculation" aria-labelledby={`${result.weaponLabel}-calculation`} aria-live="polite">
|
||||
<header>
|
||||
<div><p>{result.weaponLabel}</p><h3 id={`${result.weaponLabel}-calculation`}>{result.weaponName}</h3></div>
|
||||
<b>{result.status === 'complete' ? 'CALCULATED' : 'INSUFFICIENT DATA'}</b>
|
||||
</header>
|
||||
{#if result.status === 'insufficient'}
|
||||
<div class="missing"><strong>Calculation unavailable</strong><span>Complete the following required data:</span><ul>{#each result.missing ?? [] as item}<li>{item}</li>{/each}</ul></div>
|
||||
{:else}
|
||||
<div class="damage-grid" aria-label={`${result.weaponName} damage estimates`}>
|
||||
<span><small>BODY HIT</small><b>{format(result.bodyHit)}</b></span><span><small>CRITICAL BODY</small><b>{format(result.criticalBodyHit)}</b></span>
|
||||
<span><small>HEADSHOT</small><b>{format(result.headshot)}</b></span><span><small>CRITICAL HEADSHOT</small><b>{format(result.criticalHeadshot)}</b></span>
|
||||
</div>
|
||||
<div class="dps-grid"><span><small>BURST DPS</small><b>{format(result.burstDps)}</b></span><span><small>SUSTAINED DPS</small><b>{format(result.sustainedDps)}</b></span><span><small>ENCOUNTER DPS</small><b>{format(result.encounterDps)}</b></span></div>
|
||||
{#if result.effectSummaries?.length}
|
||||
<div class="stack-telemetry">
|
||||
<header><span>CONDITIONAL STACK TELEMETRY</span><b>{result.effectSummaries.length} EFFECTS</b></header>
|
||||
{#each result.effectSummaries as effect}
|
||||
<div>
|
||||
<strong>{effect.name}</strong>
|
||||
<span>{effect.mode.toUpperCase()}</span>
|
||||
<small>AVG {effect.averageStacks.toFixed(1)} / {effect.maxStacks}</small>
|
||||
<small>PEAK {effect.maxStacksReached} · END {effect.stacksAtEnd}</small>
|
||||
<em>{effect.mode === 'off' ? 'EXCLUDED' : effect.buildUpSeconds === null ? 'MAX NOT REACHED' : `MAX IN ${effect.buildUpSeconds.toFixed(1)}s`}</em>
|
||||
<b class:maintained={effect.maintained}>{effect.maintained ? 'MAINTAINED' : 'NOT MAINTAINED'}</b>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<footer><span>MODELED, BEFORE ENEMY MITIGATION</span>{#if result.revisionLabel}<span>{result.revisionLabel}</span>{/if}</footer>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.weapon-calculation{display:grid;gap:var(--shd-gap-md);padding:var(--shd-gap-md);border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-surface)}
|
||||
.weapon-calculation.incomplete{border-top-color:var(--shd-danger)}header{display:flex;justify-content:space-between;gap:var(--shd-gap-md);align-items:start}header p{margin:0 0 3px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.1px}h3{margin:0;font-size:var(--shd-type-body);letter-spacing:.7px;text-transform:uppercase}header>b{padding:5px 7px;border:1px solid #74552a;background:#1d180e;color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px;white-space:nowrap}.incomplete header>b{border-color:#563734;background:#1d1212;color:var(--shd-danger)}
|
||||
.damage-grid,.dps-grid{display:grid;border:1px solid var(--shd-border)}.damage-grid{grid-template-columns:repeat(2,1fr)}.dps-grid{grid-template-columns:repeat(3,1fr)}.damage-grid span,.dps-grid span{display:grid;gap:4px;padding:9px;border-left:1px solid var(--shd-border);border-top:1px solid var(--shd-border)}.damage-grid span:nth-child(odd),.dps-grid span:first-child{border-left:0}.damage-grid span:nth-child(-n+2){border-top:0}.dps-grid b{color:var(--shd-orange);font-size:20px}.damage-grid b{font-size:var(--shd-type-body)}small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.missing{display:grid;gap:4px;padding:10px;border:1px solid #563734;border-left:2px solid var(--shd-danger);background:#1d1212;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.missing strong{color:var(--shd-danger);text-transform:uppercase;font-size:var(--shd-type-telemetry);letter-spacing:.8px}.missing ul{margin:2px 0 0;padding-left:18px}.missing li+li{margin-top:2px}footer{display:flex;justify-content:space-between;gap:var(--shd-gap-sm);color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:.6px}
|
||||
.stack-telemetry{display:grid;border:1px solid var(--shd-model-border);background:var(--shd-model-surface)}.stack-telemetry>header{display:flex;align-items:center;justify-content:space-between;padding:6px 8px;border-bottom:1px solid var(--shd-model-border);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.stack-telemetry>header span{color:var(--shd-info)}.stack-telemetry>header b{color:var(--shd-text-muted)}.stack-telemetry>div{display:grid;grid-template-columns:minmax(120px,1.2fr) .45fr .7fr .8fr .85fr .8fr;gap:7px;align-items:center;padding:7px 8px;border-top:1px solid var(--shd-border-soft);font-size:var(--shd-type-telemetry)}.stack-telemetry>div:first-of-type{border-top:0}.stack-telemetry>div>span,.stack-telemetry>div>small,.stack-telemetry>div>em,.stack-telemetry>div>b{font-size:inherit;font-style:normal;letter-spacing:.5px}.stack-telemetry>div>span{color:var(--shd-orange);font-weight:900}.stack-telemetry>div>em{color:var(--shd-text-muted)}.stack-telemetry>div>b{color:var(--shd-danger);text-align:right}.stack-telemetry>div>b.maintained{color:var(--shd-success)}
|
||||
@media(max-width:820px){.stack-telemetry>div{grid-template-columns:1fr 1fr}.stack-telemetry>div>strong{grid-column:1/-1}.stack-telemetry>div>b{text-align:left}}
|
||||
@media(max-width:620px){header{display:grid}.dps-grid{grid-template-columns:1fr}.dps-grid span{border-left:0;border-top:1px solid var(--shd-border)}.dps-grid span:first-child{border-top:0}footer{display:grid}.damage-grid span{min-height:44px}}
|
||||
</style>
|
||||
@@ -41,6 +41,7 @@ export async function auditRecord(table: string, id: string): Promise<Record<str
|
||||
'specializations',
|
||||
'specialization_focuses',
|
||||
'specialization_abilities',
|
||||
'specialization_combat_effects',
|
||||
'specialization_tree_nodes',
|
||||
'roles'
|
||||
].includes(table)) {
|
||||
|
||||
@@ -6,11 +6,15 @@ import { recordIsacAudit } from '$lib/server/audit';
|
||||
import { getContributorMode } from '$lib/server/contributor-mode';
|
||||
import { notifyOversightReview } from '$lib/server/oversight-notifications';
|
||||
|
||||
export const DATA_MODULES = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips', 'specializations'] as const;
|
||||
export const DATA_MODULES = ['configuration', 'calculations', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips', 'specializations'] as const;
|
||||
export type DataModule = typeof DATA_MODULES[number];
|
||||
|
||||
const proposalResultKeys: Partial<Record<DataModule, Record<string, { saved: string; mode?: string }>>> = {
|
||||
configuration: { saveCoreRanges: { saved: 'configurationSaved' } },
|
||||
calculations: {
|
||||
saveRevision: { saved: 'revisionSaved', mode: 'revisionMode' },
|
||||
saveCalibration: { saved: 'calibrationSaved', mode: 'calibrationMode' }
|
||||
},
|
||||
attributes: { save: { saved: 'saved', mode: 'editorMode' } },
|
||||
brands: { save: { saved: 'brandSaved', mode: 'brandMode' } },
|
||||
gear: {
|
||||
@@ -34,6 +38,7 @@ const proposalResultKeys: Partial<Record<DataModule, Record<string, { saved: str
|
||||
saveSpecialization: { saved: 'specializationSaved', mode: 'specializationMode' },
|
||||
saveFocus: { saved: 'focusSaved', mode: 'focusMode' },
|
||||
saveAbility: { saved: 'abilitySaved', mode: 'abilityMode' },
|
||||
saveCombatEffect: { saved: 'combatEffectSaved', mode: 'combatEffectMode' },
|
||||
saveFocusAbilities: { saved: 'focusAbilitiesSaved' },
|
||||
saveNode: { saved: 'nodeSaved', mode: 'nodeMode' }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
|
||||
export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
const [
|
||||
attributes,
|
||||
gear,
|
||||
gearAugments,
|
||||
brands,
|
||||
brandEffects,
|
||||
weapons,
|
||||
weaponBonuses,
|
||||
talents,
|
||||
talentTiers,
|
||||
talentEffects,
|
||||
talentTemplates,
|
||||
chipSets,
|
||||
chipTwoPiece,
|
||||
chipTalents,
|
||||
chipTalentEffects,
|
||||
protocols,
|
||||
protocolPrimary,
|
||||
protocolProfiles,
|
||||
protocolSecondary,
|
||||
protocolTalents,
|
||||
protocolTalentEffects,
|
||||
specializations,
|
||||
specializationFocuses,
|
||||
specializationAbilities,
|
||||
focusAbilities,
|
||||
specializationNodes,
|
||||
specializationRanks,
|
||||
specializationRankEffects,
|
||||
specializationCombatEffects,
|
||||
specializationRankCombatEffects,
|
||||
specializationCombatModifiers,
|
||||
activeCalculationRevision,
|
||||
calculationStages,
|
||||
calculationProfiles
|
||||
] = await Promise.all([
|
||||
(await database.prepare('SELECT * FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM gear WHERE active=1 ORDER BY slot,name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot')).all(),
|
||||
(await database.prepare('SELECT * FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM brand_set_effects ORDER BY pieces_required')).all(),
|
||||
(await database.prepare('SELECT * FROM weapons WHERE active=1 ORDER BY category,name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM weapon_base_attribute_bonuses ORDER BY sort_order')).all(),
|
||||
(await database.prepare('SELECT * FROM talents WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM talent_tiers ORDER BY tier')).all(),
|
||||
(await database.prepare('SELECT * FROM talent_tier_effects')).all(),
|
||||
(await database.prepare('SELECT * FROM talent_description_templates')).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_talent_effects ORDER BY sort_order')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocols WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_secondary_profiles')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_talents')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_talent_effects ORDER BY sort_order')).all(),
|
||||
(await database.prepare('SELECT * FROM specializations WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_focuses WHERE active=1 ORDER BY specialization_id,display_order,name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_abilities WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_focus_abilities ORDER BY focus_id,ability_slot')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_tree_nodes WHERE active=1 ORDER BY focus_id,row_number,column_number')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_tree_node_ranks ORDER BY node_id,rank')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_combat_effects WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_tree_rank_combat_effects ORDER BY node_id,rank,effect_order')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_tree_rank_combat_modifiers ORDER BY rank_combat_effect_id,modifier_order')).all(),
|
||||
(await database.prepare("SELECT * FROM calculation_revisions WHERE status='active' ORDER BY version DESC LIMIT 1")).get(),
|
||||
(await database.prepare(`SELECT stage.* FROM calculation_stages stage
|
||||
JOIN calculation_revisions revision ON revision.id=stage.revision_id
|
||||
WHERE revision.status='active' ORDER BY stage.display_order`)).all(),
|
||||
(await database.prepare(`SELECT profile.* FROM attribute_calculation_profiles profile
|
||||
JOIN calculation_revisions revision ON revision.id=profile.revision_id
|
||||
WHERE revision.status='active' ORDER BY profile.stage_key,profile.stat_attribute_id`)).all()
|
||||
]);
|
||||
|
||||
const configuredTalents = talents.map((talent) => ({
|
||||
...talent,
|
||||
template: talentTemplates.find((item) => item.talent_id === talent.id)?.template ?? '',
|
||||
tiers: talentTiers
|
||||
.filter((tier) => tier.talent_id === talent.id)
|
||||
.map((tier) => ({
|
||||
...tier,
|
||||
effects: talentEffects.filter((effect) => effect.talent_tier_id === tier.id)
|
||||
}))
|
||||
}));
|
||||
|
||||
return {
|
||||
attributes,
|
||||
gear,
|
||||
gearAugments,
|
||||
brands: brands.map((brand) => ({
|
||||
...brand,
|
||||
effects: brandEffects.filter((effect) => effect.brand_set_id === brand.id)
|
||||
})),
|
||||
weapons: weapons.map((weapon) => ({
|
||||
...weapon,
|
||||
baseBonuses: weaponBonuses.filter((bonus) => bonus.weapon_id === weapon.id)
|
||||
})),
|
||||
talents: configuredTalents,
|
||||
chipSets: chipSets.map((set) => {
|
||||
const talent = chipTalents.find((item) => item.skill_chip_set_id === set.id);
|
||||
return {
|
||||
...set,
|
||||
twoPiece: chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ?? null,
|
||||
talent: talent
|
||||
? {
|
||||
...talent,
|
||||
effects: chipTalentEffects.filter((effect) => effect.talent_id === talent.id)
|
||||
}
|
||||
: null
|
||||
};
|
||||
}),
|
||||
protocols: protocols.map((protocol) => {
|
||||
const profile = protocolProfiles.find(
|
||||
(item) =>
|
||||
item.quality === protocol.quality
|
||||
&& item.primary_attribute_type === protocol.primary_attribute_type
|
||||
);
|
||||
const talent = protocolTalents.find((item) => item.protocol_id === protocol.id);
|
||||
return {
|
||||
...protocol,
|
||||
primaryEffect: protocolPrimary.find((item) => item.protocol_id === protocol.id) ?? null,
|
||||
secondaryEffects: profile
|
||||
? protocolSecondary.filter((effect) => effect.profile_id === profile.id)
|
||||
: [],
|
||||
talent: talent
|
||||
? {
|
||||
...talent,
|
||||
effects: protocolTalentEffects.filter((effect) => effect.talent_id === talent.id)
|
||||
}
|
||||
: null
|
||||
};
|
||||
}),
|
||||
specializations: specializations.map((specialization) => ({
|
||||
...specialization,
|
||||
focuses: specializationFocuses
|
||||
.filter((focus) => focus.specialization_id === specialization.id)
|
||||
.map((focus) => {
|
||||
const abilities = focusAbilities
|
||||
.filter((link) => link.focus_id === focus.id)
|
||||
.sort((left, right) => Number(left.ability_slot) - Number(right.ability_slot))
|
||||
.map((link) => specializationAbilities.find((ability) => ability.id === link.ability_id))
|
||||
.filter(Boolean);
|
||||
const nodes = specializationNodes
|
||||
.filter((node) => node.focus_id === focus.id)
|
||||
.map((node) => ({
|
||||
...node,
|
||||
ranks: specializationRanks
|
||||
.filter((rank) => rank.node_id === node.id)
|
||||
.map((rank) => ({
|
||||
...rank,
|
||||
effects: specializationRankEffects.filter(
|
||||
(effect) =>
|
||||
effect.node_id === node.id
|
||||
&& Number(effect.rank) === Number(rank.rank)
|
||||
),
|
||||
combatEffects: specializationRankCombatEffects
|
||||
.filter(
|
||||
(effect) =>
|
||||
effect.node_id === node.id
|
||||
&& Number(effect.rank) === Number(rank.rank)
|
||||
)
|
||||
.map((effect) => ({
|
||||
...effect,
|
||||
definition: specializationCombatEffects.find(
|
||||
(definition) => definition.id === effect.combat_effect_id
|
||||
),
|
||||
modifiers: specializationCombatModifiers.filter(
|
||||
(modifier) => modifier.rank_combat_effect_id === effect.id
|
||||
)
|
||||
}))
|
||||
}))
|
||||
}));
|
||||
return { ...focus, abilities, nodes };
|
||||
})
|
||||
})),
|
||||
calculation: {
|
||||
revision: activeCalculationRevision ?? null,
|
||||
stages: calculationStages,
|
||||
profiles: calculationProfiles
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user