feat: add source-owned combat mechanic programs

This commit is contained in:
JB
2026-07-27 13:35:00 -04:00
parent 5a67b252aa
commit 4eb754e7c8
62 changed files with 14815 additions and 905 deletions
+175 -28
View File
@@ -11,8 +11,10 @@
import CombatAssumptions from '$lib/components/builder/CombatAssumptions.svelte';
import ConditionalEffectCard from '$lib/components/builder/ConditionalEffectCard.svelte';
import WeaponCalculationResult from '$lib/components/builder/WeaponCalculationResult.svelte';
import { evaluateLoadoutCombat, summarizeLoadout } from '$lib/builder/loadout-combat';
import SkillCalculationResult from '$lib/components/builder/SkillCalculationResult.svelte';
import { evaluateLoadoutCombat, evaluateLoadoutSkillCombat, summarizeLoadout } from '$lib/builder/loadout-combat';
import { toast } from '$lib/toast';
import { MECHANIC_EVENT_REGISTRY, type MechanicEventKind } from '$lib/calculations/mechanic';
import {
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, weaponBaseRange,
type LoadoutDraft, type Roll
@@ -52,7 +54,9 @@
return selectedRank ? [{ node, rank: node.ranks.find((entry: any) => Number(entry.rank) === selectedRank) }] : [];
}) ?? []);
const selectedCombatEffects = $derived(selectedNodeRanks.flatMap(({ node, rank }: any) =>
(rank?.combatEffects ?? []).map((effect: any) => ({ ...effect, nodeName: node.name }))
(rank?.combatEffects ?? [])
.filter((effect: any) => Number(effect.rule?.configured) === 1)
.map((effect: any) => ({ ...effect, nodeName: node.name }))
));
const talent = (id: string) => data.talents.find((item) => item.id === id);
const brand = (id: string) => data.brands.find((item) => item.id === id);
@@ -62,19 +66,69 @@
const unit = attribute(statId)?.unit ?? '';
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
};
const jsonObject = (value: unknown) => {
if (value && typeof value === 'object' && !Array.isArray(value)) return value as Record<string, any>;
if (typeof value !== 'string') return {};
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, any>
: {};
} catch {
return {};
}
};
const mechanicScenarioInputs = (rule: any) => {
const raw = rule?.rule?.scenario_inputs_json;
if (Array.isArray(raw)) return raw;
if (typeof raw !== 'string') return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
const combatEffectDescription = (effect: any) => {
const template = String(effect.definition?.description_template ?? effect.definition?.name ?? 'Conditional effect');
const rule = effect.rule;
const outcomes = (rule?.outcomes ?? []).filter((outcome: any) => outcome.variant_key === 'base');
const firstReducer = jsonObject(rule?.clauses?.[0]?.reducer_json);
const primaryPool = rule?.statePools?.[0];
return template
.replaceAll('{triggerCount}', String(effect.trigger_count))
.replaceAll('{triggerWindow}', `${effect.trigger_window_seconds}s`)
.replaceAll('{duration}', String(effect.duration_seconds))
.replaceAll('{maxStacks}', String(effect.max_stacks))
.replaceAll('{triggerCount}', String(firstReducer.threshold ?? ''))
.replaceAll('{triggerWindow}', firstReducer.windowSeconds ? `${firstReducer.windowSeconds}s` : '')
.replaceAll('{duration}', String(primaryPool?.duration_seconds ?? 0))
.replaceAll('{maxStacks}', String(primaryPool?.maximum_value ?? 1))
.replaceAll('{effectName}', String(effect.definition?.name ?? 'Effect'))
.replace(/\{(\d+)\}/g, (placeholder: string, raw: string) => {
const modifier = effect.modifiers[Number(raw) - 1];
return modifier ? unitValue(modifier.value_per_stack, modifier.stat_attribute_id) : placeholder;
const outcome = outcomes[Number(raw) - 1];
return outcome?.stat_attribute_id
? unitValue(outcome.value, outcome.stat_attribute_id)
: placeholder;
});
};
const conditionalFacts = (rule: any) => {
const clauses = rule?.clauses ?? [];
const eventLabels = [...new Set(clauses.map((clause: any) => {
const event = String(clause.event_kind ?? '') as MechanicEventKind;
return MECHANIC_EVENT_REGISTRY[event]?.label;
}).filter(Boolean))];
const readiness = String(rule?.rule?.simulation_readiness ?? 'captured_only')
.replaceAll('_', ' ')
.toUpperCase();
const cooldowns = clauses
.map((clause: any) => Number(clause.cooldown_seconds ?? 0))
.filter((value: number) => value > 0);
const poolMaximum = Math.max(0, ...(rule?.statePools ?? [])
.map((pool: any) => Number(pool.maximum_value ?? 0)));
return [
`${clauses.length} BRANCH${clauses.length === 1 ? '' : 'ES'}`,
eventLabels.length ? eventLabels.join(' · ').toUpperCase() : 'NO EVENT BRANCHES',
poolMaximum > 1 ? `MAX ${poolMaximum} STACKS` : readiness,
cooldowns.length ? `${Math.max(...cooldowns)}s COOLDOWN` : 'NO COOLDOWN'
];
};
const scaledRange = (minimum: unknown, maximum: unknown, level: unknown, referenceLevel: unknown, source: 'gear' | 'weapon') =>
levelScaledRange(minimum, maximum, level, referenceLevel ?? 50, source);
const gearBaseRange = (item: any, key: 'health' | 'armor', level: number) =>
@@ -159,15 +213,23 @@
draft.specialization = {
...draft.specialization,
focusId,
nodeRanks: {},
effectStates: {}
nodeRanks: {}
};
active = 'specialization:focus';
};
const toggleTalent = (key: string) => {
draft.activeTalents = draft.activeTalents.includes(key)
? draft.activeTalents.filter((item) => item !== key)
: [...draft.activeTalents, key];
const current = draft.conditionalEffects[key]?.mode ?? 'off';
draft.conditionalEffects = {
...draft.conditionalEffects,
[key]: { mode: current === 'off' ? 'peak' : 'off' }
};
};
const setSkillActivation = (abilityId: string, patch: Record<string, unknown>) => {
const current = draft.skillActivations[abilityId] ?? { mode: 'off', targets: 1 };
draft.skillActivations = {
...draft.skillActivations,
[abilityId]: { ...current, ...patch }
};
};
const talentTier = (talentId: string, tier: number) => {
const item = talent(talentId);
@@ -289,19 +351,58 @@
return `Talent Cooldown: ${value} ${Number(value) === 1 ? 'second' : 'seconds'}`;
};
const summary = $derived(summarizeLoadout(draft, data));
const combatResults = $derived(evaluateLoadoutCombat(draft, data, summary));
const modeledTalents = $derived(summary.equippedTalents.filter(
(equipped: any) => Number(equipped.rule?.rule?.configured) === 1
&& (equipped.rule?.clauses?.length ?? 0) > 0
));
const calculationDraft = $derived.by(() => {
if (draft.calculationMode === 'custom') return draft;
const effectKeys = [
...selectedCombatEffects.map((effect: any) => String(effect.id)),
...modeledTalents.map((effect: any) => String(effect.key))
];
const conditionalEffects = {
...draft.conditionalEffects,
...Object.fromEntries(effectKeys.map((key) => [
key,
{ ...draft.conditionalEffects[key], mode: 'peak' as const }
]))
};
const skillActivations = { ...draft.skillActivations };
for (const ability of selectedFocus?.abilities ?? []) {
skillActivations[ability.id] = {
mode: 'on_cooldown',
targets: Math.max(1, Number(ability.attack?.target_cap ?? 1))
};
}
return { ...draft, conditionalEffects, skillActivations };
});
const combatResults = $derived(evaluateLoadoutCombat(calculationDraft, data, summary));
const skillCombatResult = $derived(evaluateLoadoutSkillCombat(calculationDraft, data, summary));
const skillAbilityNames = $derived(Object.fromEntries(
(selectedFocus?.abilities ?? []).map((ability: any) => [String(ability.id), String(ability.name)])
));
$effect(() => {
const missing = selectedCombatEffects.filter((effect: any) => !draft.specialization.effectStates[effect.id]);
if (!missing.length) return;
draft.specialization.effectStates = {
...draft.specialization.effectStates,
...Object.fromEntries(missing.map((effect: any) => [effect.id, { mode: 'off' }]))
const sources = [
...selectedCombatEffects.map((effect: any) => ({ key: String(effect.id) })),
...modeledTalents
];
const missing = sources.filter((source: any) => !draft.conditionalEffects[source.key]);
if (missing.length) draft.conditionalEffects = {
...draft.conditionalEffects,
...Object.fromEntries(missing.map((effect: any) => [effect.key, { mode: 'off' }]))
};
const missingAbilities = (selectedFocus?.abilities ?? []).filter((ability: any) => !draft.skillActivations[ability.id]);
if (missingAbilities.length) draft.skillActivations = {
...draft.skillActivations,
...Object.fromEntries(missingAbilities.map((ability: any) => [ability.id, { mode: 'off', targets: 1 }]))
};
});
onMount(() => {
if (data.initialBuild) return;
const stored = localStorage.getItem('shd-build-loadout-draft-v2');
localStorage.removeItem('shd-build-loadout-draft-v3');
const stored = localStorage.getItem('shd-build-loadout-draft-v4');
if (!stored) return;
try {
const parsed = parseLoadoutDraft(JSON.parse(stored));
@@ -309,9 +410,9 @@
draft = parsed;
if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = '';
saved = true;
} else localStorage.removeItem('shd-build-loadout-draft-v2');
} else localStorage.removeItem('shd-build-loadout-draft-v4');
} catch {
localStorage.removeItem('shd-build-loadout-draft-v2');
localStorage.removeItem('shd-build-loadout-draft-v4');
}
});
$effect(() => {
@@ -327,7 +428,7 @@
: 'Build published to the SHD archive.';
});
function saveDraft() {
localStorage.setItem('shd-build-loadout-draft-v2', JSON.stringify(draft));
localStorage.setItem('shd-build-loadout-draft-v4', JSON.stringify(draft));
saved = true;
toast.success('Draft saved locally', 'This loadout is available on this device.');
}
@@ -365,7 +466,7 @@
{#if persistedSlug && persistedVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
</div>
{/if}
<div class="builder-status"><span><i></i> LIVE CALCULATION</span><span>18 EQUIPMENT POSITIONS</span><span>{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS</span></div>
<div class="builder-status"><span><i></i> LIVE CALCULATION</span><span>{draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : 'CUSTOM SCENARIO'}</span><span>18 EQUIPMENT POSITIONS</span><span>{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS</span></div>
</div>
{#if message}<p class="success" role="status">{message}</p>{/if}
{#if form?.buildError}<p class="error" role="alert">{form.buildError}</p>{/if}
@@ -394,6 +495,20 @@
</section>
{/each}
</div>
{#if draft.calculationMode === 'custom'}<div class="builder-skill-activation">
<div class="builder-section-title"><div><span>SKILL ACTIVATION PROFILE</span><small>ALL ENABLED ABILITIES CONTRIBUTE TO COMBINED SKILL DPS</small></div><b>{selectedFocus.abilities.filter((ability: any) => (draft.skillActivations[ability.id]?.mode ?? 'off') !== 'off').length} ENABLED</b></div>
{#each selectedFocus.abilities as ability}
{@const activation = draft.skillActivations[ability.id] ?? { mode: 'off', targets: 1 }}
<section>
<header><ItemIcon src={ability.iconUrl} alt=""/><div><strong>{ability.name}</strong><small>{ability.attack ? `${ability.attack.delivery.toUpperCase()} · ${ability.attack.damage_type}` : 'ATTACK PROFILE NOT CONFIGURED'}</small></div></header>
<SegmentedChoice legend={`${ability.name} activation`} name={`skill-${ability.id}-activation`} options={[{value:'off',label:'OFF'},{value:'on_cooldown',label:'ON COOLDOWN'},{value:'custom_per_minute',label:'CUSTOM / MIN'}]} value={activation.mode} onchange={(values) => setSkillActivation(ability.id,{mode:values[0]})}/>
<div class="builder-skill-assumptions">
{#if activation.mode === 'custom_per_minute'}<RangeValueControl label="Uses per minute" min={0.1} max={60} step={0.1} value={activation.usesPerMinute ?? 1} onchange={(value) => setSkillActivation(ability.id,{usesPerMinute:value})}/>{/if}
<RangeValueControl label="Targets hit" min={1} max={Math.max(1,Number(ability.attack?.target_cap ?? 1))} step={1} value={activation.targets} onchange={(value) => setSkillActivation(ability.id,{targets:value})}/>
</div>
</section>
{/each}
</div>{/if}
<div class="builder-specialization-telemetry"><span>POINTS <b>{selectedSpecializationPoints} / {data.specializationProgression.availablePoints}</b></span><span>CREDITS <b>{selectedSpecializationCosts.credits.toLocaleString()}</b></span>{#each selectedSpecializationCosts.resources as resource}<span>{String(resource[0]).replace('resource-electronics-','').replaceAll('-',' ').toUpperCase()} <b>× {resource[1]}</b></span>{/each}</div>
<div class="builder-focus-tree">
<div class="builder-section-title"><div><span>FOCUS UPGRADE TREE</span><small>PREVIOUS ROWS REQUIRE EVERY AVAILABLE RANK 1 + 2 BEFORE ADVANCING</small></div><b>{selectedFocus.nodes.length} NODES</b></div>
@@ -405,9 +520,40 @@
</div></section>
{/each}
</div>
<CombatAssumptions bind:profile={draft.specialization.combatProfile}/>
<CombatAssumptions bind:mode={draft.calculationMode} bind:profile={draft.combatProfile}/>
{#if selectedCombatEffects.length}
<div class="builder-conditional-effects"><div class="builder-section-title"><div><span>CONDITIONAL EFFECT PROFILES</span><small>CHOOSE HOW EACH UNLOCKED EFFECT CONTRIBUTES TO DPS</small></div><b>{selectedCombatEffects.length} EFFECTS</b></div>{#each selectedCombatEffects as effect}<ConditionalEffectCard id={effect.id} name={effect.definition?.name ?? effect.nodeName} description={combatEffectDescription(effect)} facts={[`${effect.trigger_count} HITS WITHIN ${effect.trigger_window_seconds}s`, `+${effect.stacks_per_trigger} STACK · MAX ${effect.max_stacks}`, `EXPIRES ${effect.duration_seconds}s AFTER LAST GAIN`, ...effect.modifiers.map((modifier: any) => `${unitValue(modifier.value_per_stack,modifier.stat_attribute_id)} ${attribute(modifier.stat_attribute_id)?.name ?? 'ATTRIBUTE'} PER STACK`)]} appliedState={draft.specialization.effectStates[effect.id]?.mode === 'peak' ? `APPLIED: ${effect.max_stacks}/${effect.max_stacks}` : draft.specialization.effectStates[effect.id]?.mode === 'encounter' ? `APPLIED: 0→${effect.max_stacks}/${effect.max_stacks}` : 'NOT APPLIED'} bind:effectState={draft.specialization.effectStates[effect.id]}/>{/each}</div>
<div class="builder-conditional-effects">
<div class="builder-section-title"><div><span>CONDITIONAL EFFECT PROFILES</span><small>CHOOSE HOW EACH UNLOCKED EFFECT CONTRIBUTES TO DPS</small></div><b>{selectedCombatEffects.length} EFFECTS</b></div>
{#each selectedCombatEffects as effect}
<ConditionalEffectCard
id={effect.id}
name={effect.definition?.name ?? effect.nodeName}
description={combatEffectDescription(effect)}
facts={conditionalFacts(effect.rule)}
scenarioInputs={mechanicScenarioInputs(effect.rule)}
appliedState={draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : draft.conditionalEffects[effect.id]?.mode === 'peak' ? 'PEAK MODE' : draft.conditionalEffects[effect.id]?.mode === 'encounter' ? 'ENCOUNTER MODE' : 'NOT APPLIED'}
bind:effectState={draft.conditionalEffects[effect.id]}
calculationMode={draft.calculationMode}
/>
{/each}
</div>
{/if}
{#if modeledTalents.length}
<div class="builder-conditional-effects">
<div class="builder-section-title"><div><span>EQUIPPED TALENT MODELING</span><small>MODEL EACH MECHANIC PROGRAM OR EXCLUDE IT FROM CALCULATIONS</small></div><b>{modeledTalents.length} TALENTS</b></div>
{#each modeledTalents as equipped}
<ConditionalEffectCard
id={equipped.key}
name={equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}
description={equipped.description}
facts={conditionalFacts(equipped.rule)}
scenarioInputs={mechanicScenarioInputs(equipped.rule)}
appliedState={draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : draft.conditionalEffects[equipped.key]?.mode === 'peak' ? 'PEAK MODE' : draft.conditionalEffects[equipped.key]?.mode === 'encounter' ? 'ENCOUNTER MODE' : 'NOT APPLIED'}
bind:effectState={draft.conditionalEffects[equipped.key]}
calculationMode={draft.calculationMode}
/>
{/each}
</div>
{/if}
{/if}
{:else if activeParts[0] === 'gear'}
@@ -478,12 +624,13 @@
<div class="summary-head"><div><p class="eyebrow"><span></span> LIVE SUMMARY</p><h2>AGENT STATS</h2></div><b>{summary.stats.length}</b></div>
<div class="summary-core">{#each ['Firepower','Toughness','Engineering'] as name}{@const stat = summary.stats.find((entry) => attribute(entry.id)?.name === name)}<div><GameIcon name={name.toLowerCase() as GameIconName} decorative/><span>{name}<b>{stat ? unitValue(stat.value,stat.id) : '0'}</b></span></div>{/each}</div>
<div class="builder-combat-results">{#each combatResults as result}<WeaponCalculationResult {result}/>{/each}</div>
<div class="builder-combat-results"><SkillCalculationResult result={skillCombatResult} abilityNames={skillAbilityNames}/></div>
<div class="summary-scroll">
<div class="summary-section"><header><span>COMBINED ATTRIBUTES</span><b>{summary.stats.length}</b></header>{#each summary.stats as stat}<div class="summary-stat"><span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span><b>{unitValue(stat.value,stat.id)}</b></div>{:else}<p>Equip and configure items to calculate attributes.</p>{/each}</div>
<div class="summary-section"><header><span>ACTIVE SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>{#each summary.activeBonuses as bonus}<div class="summary-bonus"><span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span><em>+{unitValue(bonus.value,bonus.statId)} {attribute(bonus.statId)?.name}</em></div>{:else}<p>Matching Brand Sets and Skill Chips activate bonuses here.</p>{/each}</div>
<div class="summary-section"><header><span>EQUIPPED TALENTS</span><b>{summary.equippedTalents.length}</b></header>{#each summary.equippedTalents as equipped}<label class="summary-talent"><input type="checkbox" checked={draft.activeTalents.includes(equipped.key)} onchange={() => toggleTalent(equipped.key)}/><span><b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b><small>{equipped.source} · {draft.activeTalents.includes(equipped.key) ? 'TRIGGER ACTIVE' : 'CONDITIONAL'}</small><em>{equipped.description}</em>{#if equipped.id && talentCooldown(equipped.id)}<i class="talent-cooldown">{talentCooldown(equipped.id)}</i>{/if}</span></label>{:else}<p>Selected equipment talents appear here.</p>{/each}</div>
<div class="summary-section"><header><span>EQUIPPED TALENTS</span><b>{summary.equippedTalents.length}</b></header>{#each summary.equippedTalents as equipped}<label class="summary-talent">{#if draft.calculationMode === 'custom'}<input type="checkbox" checked={(draft.conditionalEffects[equipped.key]?.mode ?? 'off') !== 'off'} onchange={() => toggleTalent(equipped.key)}/>{/if}<span><b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b><small>{equipped.source} · {draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : (draft.conditionalEffects[equipped.key]?.mode ?? 'off') !== 'off' ? 'INCLUDED' : 'CONDITIONAL'}</small><em>{equipped.description}</em>{#if equipped.id && talentCooldown(equipped.id)}<i class="talent-cooldown">{talentCooldown(equipped.id)}</i>{/if}</span></label>{:else}<p>Selected equipment talents appear here.</p>{/each}</div>
</div>
<p class="notice">Talent effects enter totals only while marked trigger active. Anonymous drafts remain on this device.</p>
<p class="notice">{draft.calculationMode === 'maximum' ? 'Maximum Potential applies every configured effect at best-case uptime. ' : 'Custom Scenario uses each mechanic program and the encounter settings saved with this loadout. '}Anonymous drafts remain on this device.</p>
</aside>
</div>
</main>