feat: defer advanced build calculations
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 27s
Validate and deploy staging / deploy (push) Successful in 54s

This commit is contained in:
jb
2026-08-02 03:07:12 -04:00
parent c61b9832dd
commit d09b6220ee
19 changed files with 1485 additions and 1267 deletions
+1 -1
View File
@@ -230,7 +230,7 @@
<header><div><p class="eyebrow">LOADOUT ARCHIVE</p><h2>PUBLIC BUILDS</h2></div><span>{data.counts.builds} TOTAL</span></header>
<div class="public-content-grid">
{#each data.builds as build}
<a class="public-content-card" href={`/builds/${encodeURIComponent(build.slug)}`}><span>LOADOUT</span><h3>{build.title}</h3><p>Open this loadout to inspect its equipment, Attributes, and calculated performance.</p><footer><small>UPDATED {dateLabel(build.updatedAt)}</small><b>VIEW BUILD →</b></footer></a>
<a class="public-content-card" href={`/builds/${encodeURIComponent(build.slug)}`}><span>LOADOUT</span><h3>{build.title}</h3><p>Open this loadout to inspect its equipment, known Attributes, and active set bonuses.</p><footer><small>UPDATED {dateLabel(build.updatedAt)}</small><b>VIEW BUILD →</b></footer></a>
{:else}<div class="public-empty"><span>◇</span><strong>NO PUBLIC BUILDS</strong><p>This Agent has not shared a public loadout.</p></div>{/each}
</div>
</section>
-113
View File
@@ -67,10 +67,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
talentTiers,
talentEffects,
talentTemplates,
conditionalRules,
conditionalPools,
conditionalClauses,
conditionalOutcomes,
chipSets,
chipTwoPiece,
chipPrimary,
@@ -94,15 +90,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
specializationRanks,
specializationRankEffects,
specializationRankCosts,
specializationCombatEffects,
specializationRankCombatEffects,
specializationCombatModifiers,
progression,
rankProfiles,
activeCalculationRevision,
calculationStages,
calculationProfiles,
skillCalculationProfiles,
] = await Promise.all([
(
await database.prepare(
@@ -164,26 +153,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(
await database.prepare('SELECT * FROM talent_description_templates')
).all(),
(
await database.prepare(
'SELECT * FROM conditional_effect_rules WHERE configured=1',
)
).all(),
(
await database.prepare(
'SELECT * FROM conditional_effect_state_pools ORDER BY rule_id,sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM conditional_effect_clauses WHERE active=1 ORDER BY rule_id,sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM conditional_effect_outcomes ORDER BY rule_id,variant_key,outcome_key,sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
@@ -271,21 +240,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'SELECT * FROM specialization_tree_rank_costs ORDER BY node_id,rank,resource_id',
)
).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 specialization_progression_config WHERE id='default'",
@@ -296,26 +250,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'SELECT * FROM specialization_rank_cost_profiles ORDER BY rank',
)
).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(),
(
await database.prepare(`SELECT profile.* FROM skill_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 iconUrl = (
type:
@@ -333,26 +267,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
);
return icon ? gameDataIconUrl(type, String(id), icon.updated_at) : '';
};
const hydrateRule = (sourceType: string, sourceId: unknown) => {
const rule = conditionalRules.find(
(item) => item.source_type === sourceType && item.source_id === sourceId,
);
return rule
? {
rule,
statePools: conditionalPools.filter(
(pool) => pool.rule_id === rule.id,
),
clauses: conditionalClauses.filter(
(clause) => clause.rule_id === rule.id,
),
outcomes: conditionalOutcomes.filter(
(outcome) => outcome.rule_id === rule.id,
),
triggers: [],
}
: null;
};
const configuredTalents = talents.map((talent) => ({
...talent,
iconUrl: iconUrl('talent', talent.id),
@@ -367,7 +281,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(effect) => effect.talent_tier_id === tier.id,
),
})),
rule: hydrateRule('talent', talent.id),
}));
return {
viewer: viewer ? { id: viewer.id, displayName: viewer.displayName } : null,
@@ -436,7 +349,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
effects: chipTalentEffects.filter(
(effect) => effect.talent_id === talent.id,
),
rule: hydrateRule('skill_chip_talent', talent.id),
}
: null,
};
@@ -467,7 +379,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
effects: protocolTalentEffects.filter(
(effect) => effect.talent_id === talent.id,
),
rule: hydrateRule('os_protocol_talent', talent.id),
}
: null,
};
@@ -520,24 +431,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
cost.node_id === node.id &&
Number(cost.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,
),
rule: hydrateRule('specialization_tree_rank', effect.id),
})),
})),
}));
return { ...focus, abilities: assignedAbilities, nodes };
@@ -550,12 +443,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
Number(progression?.shd_cache_points ?? 19),
rankProfiles,
},
calculation: {
revision: activeCalculationRevision,
stages: calculationStages,
profiles: calculationProfiles,
skillProfiles: skillCalculationProfiles,
},
};
};
+12 -217
View File
@@ -8,14 +8,11 @@
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import ItemIcon from '$lib/components/ItemIcon.svelte';
import CombatAssumptions from '$lib/components/builder/CombatAssumptions.svelte';
import ConditionalEffectCard from '$lib/components/builder/ConditionalEffectCard.svelte';
import LoadoutMatrix, { type MatrixCell } from '$lib/components/builder/LoadoutMatrix.svelte';
import WeaponCalculationResult from '$lib/components/builder/WeaponCalculationResult.svelte';
import SkillCalculationResult from '$lib/components/builder/SkillCalculationResult.svelte';
import { evaluateLoadoutCombat, evaluateLoadoutSkillCombat, summarizeLoadout } from '$lib/builder/loadout-combat';
import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte';
import PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte';
import { summarizeLoadout } from '$lib/builder/loadout-summary';
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
@@ -54,11 +51,6 @@
const selectedRank = Number(draft.specialization.nodeRanks[node.id] ?? 0);
return selectedRank ? [{ node, rank: node.ranks.find((entry: any) => Number(entry.rank) === selectedRank) }] : [];
}) ?? []);
const selectedCombatEffects = $derived(selectedNodeRanks.flatMap(({ node, rank }: any) =>
(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);
const unitValue = (value: unknown, statId: string) => {
@@ -67,69 +59,6 @@
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(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 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) =>
@@ -218,20 +147,6 @@
};
active = 'specialization:focus';
};
const toggleTalent = (key: string) => {
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);
return item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ?? item?.tiers[0];
@@ -346,43 +261,7 @@
selection.level = nextLevel;
};
const activeParts = $derived(active.split(':'));
const talentCooldown = (id: string) => {
const value = talent(id)?.cooldown_seconds;
if (value === null || value === undefined || value === '') return '';
return `Talent Cooldown: ${value} ${Number(value) === 1 ? 'second' : 'seconds'}`;
};
const summary = $derived(summarizeLoadout(draft, data));
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)])
));
const rollSummary = (rolls: Roll[]) => rolls
.filter((roll) => roll.statId)
.map((roll) => `${attribute(roll.statId)?.name ?? 'Attribute'} ${unitValue(roll.value, roll.statId)}`)
@@ -410,7 +289,7 @@
? `${selectedFocus.primary_attribute} · ${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS`
: 'ROOT CHOICE · DEFINES ALL THREE ABILITIES',
iconUrl: selectedFocus?.specialization.iconUrl,
issue: !selectedFocus ? 'REQUIRED FOR CALCULATION' : selectedFocus.abilities.length !== 3 ? 'ABILITY DATA INCOMPLETE' : undefined
issue: !selectedFocus ? 'SELECT TO COMPLETE LOADOUT' : selectedFocus.abilities.length !== 3 ? 'ABILITY DATA INCOMPLETE' : undefined
});
const abilityCells = $derived.by((): MatrixCell[] => [0, 1, 2].map((index) => {
const ability = selectedFocus?.abilities[index];
@@ -437,7 +316,7 @@
meta: item?.category ?? '',
summary: item ? details || `T${selection.tier} · ${unitValue(selection.damage, '')} DAMAGE` : 'SELECT WEAPON',
iconUrl: weaponIcon(item?.category),
issue: item ? undefined : 'REQUIRED FOR CALCULATION'
issue: item ? undefined : 'SELECT TO COMPLETE LOADOUT'
};
}));
const gearCells = $derived.by((): MatrixCell[] => gearSlots.map((slot) => {
@@ -455,7 +334,7 @@
meta: item ? `T${item.tier}` : '',
summary: item ? details || `T${item.tier} · LEVEL ${selection.level}` : 'SELECT GEAR',
iconUrl: gearIcon(slot),
issue: item ? undefined : 'REQUIRED FOR CALCULATION'
issue: item ? undefined : 'SELECT TO COMPLETE LOADOUT'
};
}));
const protocolCell = $derived<MatrixCell>({
@@ -467,7 +346,7 @@
? `LEVEL ${draft.protocol.level} · ${draft.protocol.talentUpgrades} / 5 TALENT UPGRADES`
: 'SELECT ONE PROTOCOL',
iconUrl: protocolItem()?.iconUrl,
issue: protocolItem() ? undefined : 'REQUIRED FOR CALCULATION'
issue: protocolItem() ? undefined : 'SELECT TO COMPLETE LOADOUT'
});
const loadoutConfigured = $derived(Boolean(
selectedFocus
@@ -478,22 +357,6 @@
&& draft.protocol.itemId
));
$effect(() => {
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;
localStorage.removeItem('shd-build-loadout-draft-v3');
@@ -561,7 +424,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>{draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : 'CUSTOM SCENARIO'}</span><span>12 LOADOUT CELLS + 9 CHIPS</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> KNOWN STATS ONLINE</span><span>ADVANCED MODEL · COMING SOON</span><span>12 LOADOUT CELLS + 9 CHIPS</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}
@@ -594,20 +457,6 @@
</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>
@@ -619,41 +468,7 @@
</div></section>
{/each}
</div>
<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={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}
<PlannedCombatModel headingId="builder-combat-model-heading" />
{/if}
{:else if activeParts[0] === 'ability'}
{@const abilityIndex = Number(activeParts[1])}
@@ -668,19 +483,6 @@
<div>{#each [0,1,2] as chipSlot}{@const chipIndex = abilityIndex * 3 + chipSlot}{@const item = chipItem(chipIndex)}<button type="button" onclick={() => active = `chip:${chipIndex}`}><small>CHIP {chipSlot + 1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${draft.chips[chipIndex].quality} · ${attribute(draft.chips[chipIndex].primary.statId)?.name ?? 'NO PRIMARY'}` : 'SELECT CHIP'}</span></button>{/each}</div>
</section>
</div>
{#if draft.calculationMode === 'custom'}
{@const activation = draft.skillActivations[ability.id] ?? { mode: 'off', targets: 1 }}
<div class="builder-skill-activation">
<div class="builder-section-title"><div><span>ACTIVATION PROFILE</span><small>CONTROLS THIS ABILITY'S CONTRIBUTION TO COMBINED SKILL DPS</small></div></div>
<section>
<SegmentedChoice legend={`${ability.name} activation`} name={`ability-${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>
</div>
{/if}
{:else}
<p class="error">Select a Specialization and Focus with configured abilities before attaching Skill Chips.</p>
{/if}
@@ -749,16 +551,9 @@
</section>
<aside class="loadout-summary">
<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} resolverHint="Use the warning markers in the loadout matrix."/>{/each}</div>
<div class="builder-combat-results"><SkillCalculationResult result={skillCombatResult} abilityNames={skillAbilityNames} resolverHint="Open the marked doctrine or ability cell."/></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">{#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">{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>
<LoadoutStatsSummary {summary} attributes={data.attributes} talents={data.talents} headingId="builder-known-stats-heading" />
<div class="builder-planned-model"><PlannedCombatModel headingId="builder-planned-output-heading" variant="compact" /></div>
<p class="notice">Anonymous drafts remain on this device. Account saves preserve the equipped-item configuration and current visibility.</p>
</aside>
</div>
</main>
+16 -15
View File
@@ -2,11 +2,8 @@ import { error } from '@sveltejs/kit';
import { getDatabase } from '$lib/server/database';
import { getSessionUser } from '$lib/server/auth/session';
import { parseLoadoutDraft, type LoadoutDraft } from '$lib/builder/loadout';
import {
evaluateLoadoutCombat,
summarizeLoadout,
} from '$lib/builder/loadout-combat';
import { loadSharedBuildCombatCatalog } from '$lib/server/shared-build-combat';
import { summarizeLoadout } from '$lib/builder/loadout-summary';
import { loadSharedBuildSummaryCatalog } from '$lib/server/shared-build-summary';
import { avatarUrl } from '$lib/avatar';
import type { PageServerLoad } from './$types';
@@ -41,24 +38,26 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
} catch {
error(404, 'Build data is unavailable');
}
const [gear, weapons, chips, protocols, brands, combatCatalog] =
const [gear, weapons, chips, protocols, brands, summaryCatalog] =
await Promise.all([
(await database.prepare('SELECT id,name,slot,tier FROM gear')).all(),
(
await database.prepare('SELECT id,name,category,is_exotic FROM weapons')
await database.prepare(
'SELECT id,name,slot,tier,base_armor_min FROM gear',
)
).all(),
(
await database.prepare(
'SELECT id,name,category,is_exotic,rpm,magazine,reload_speed,damage_type FROM weapons',
)
).all(),
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
(
await database.prepare('SELECT id,name,quality FROM os_protocols')
).all(),
(await database.prepare('SELECT id,name FROM brand_sets')).all(),
loadSharedBuildCombatCatalog(database),
loadSharedBuildSummaryCatalog(database),
]);
const combatResults = evaluateLoadoutCombat(
loadout,
combatCatalog,
summarizeLoadout(loadout, combatCatalog),
);
const summary = summarizeLoadout(loadout, summaryCatalog);
return {
build: {
id: String(build.id),
@@ -85,6 +84,8 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
chips,
protocols,
brands,
combatResults,
summary,
attributes: summaryCatalog.attributes,
talents: summaryCatalog.talents,
};
};
+17 -16
View File
@@ -1,13 +1,22 @@
<script lang="ts">
import { gearSlots } from '$lib/builder/loadout';
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import WeaponCalculationResult from '$lib/components/builder/WeaponCalculationResult.svelte';
import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte';
import PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte';
let { data } = $props();
const gear = (id: string) => data.gear.find((item) => item.id === id);
const weapon = (id: string) => data.weapons.find((item) => item.id === id);
const chip = (id: string) => data.chips.find((item) => item.id === id);
const protocol = (id: string) => data.protocols.find((item) => item.id === id);
const brand = (id: string) => data.brands.find((item) => item.id === id);
const attribute = (id: string) => data.attributes.find((item) => item.id === id);
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
const rollLine = (rolls: Array<{ statId: string; value: number }>) => rolls
.filter((roll) => roll.statId)
.map((roll) => `${attribute(roll.statId)?.name ?? 'Attribute'} ${number.format(roll.value)}${attribute(roll.statId)?.unit ?? ''}`)
.join(' · ');
const coreLine = (selection: { firepower: number; toughness: number; engineering: number }) =>
`FP ${number.format(selection.firepower)} · TGH ${number.format(selection.toughness)} · ENG ${number.format(selection.engineering)}`;
</script>
<svelte:head><title>{data.build.title} — SHD.build</title></svelte:head>
@@ -16,30 +25,22 @@
<div><p class="eyebrow"><span></span> SHD LOADOUT ARCHIVE / {data.build.visibility.toUpperCase()}</p><h1>{data.build.title}</h1><AgentIdentityLink size="compact" linked={data.build.profileAvailable} agent={{ id: data.build.ownerId, displayName: data.build.author, publicSlug: data.build.authorPublicSlug, avatarUrl: data.build.authorAvatarUrl }} meta={`UPDATED ${new Date(data.build.updatedAt).toLocaleDateString()}`}/></div>
<div>{#if data.build.owner}<a class="secondary" href={`/guides/new?build=${encodeURIComponent(data.build.id)}`}>CREATE GUIDE FOR BUILD</a><a class="secondary" href={`/builder?build=${data.build.slug}`}>EDIT LOADOUT</a>{/if}<a class="primary" href="/builder">NEW BUILD +</a></div>
</header>
<section class="builder-combat-results" aria-labelledby="shared-combat-heading">
<div class="builder-section-title">
<div>
<span>LIVE COMBAT MODEL</span>
<h2 id="shared-combat-heading">CURRENT WEAPON OUTPUT</h2>
</div>
<b>CURRENT ISAC RULES</b>
</div>
{#each data.combatResults as result}
<WeaponCalculationResult {result}/>
{/each}
</section>
<div class="shared-build-intelligence">
<LoadoutStatsSummary summary={data.summary} attributes={data.attributes} talents={data.talents} headingId="shared-known-stats-heading" layout="page" />
<PlannedCombatModel headingId="shared-planned-model-heading" headingLevel="h2" variant="compact" />
</div>
<div class="shared-build-grid">
<section>
<header><span>WEAPONS</span><b>02 SLOTS</b></header>
{#each data.loadout.weapons as selection, index}{@const item=weapon(selection.itemId)}<article><small>{index ? 'SECONDARY' : 'PRIMARY'}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${item.category} · T${selection.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span></article>{/each}
{#each data.loadout.weapons as selection, index}{@const item=weapon(selection.itemId)}<article><small>{index ? 'SECONDARY' : 'PRIMARY'}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${item.category} · ${item.damage_type} · T${selection.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span>{#if item}<div class="shared-item-facts"><b>{number.format(selection.damage)} DAMAGE</b><b>{item.rpm} RPM</b><b>{item.magazine} ROUNDS</b><b>{item.reload_speed}s RELOAD</b></div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</article>{/each}
</section>
<section>
<header><span>GEAR</span><b>06 SLOTS</b></header>
{#each gearSlots as slot}{@const selection=data.loadout.gear[slot]}{@const item=gear(selection.itemId)}<article><small>{slot.toUpperCase()}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${brand(selection.brandId)?.name ?? 'NO BRAND'} · T${item.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span></article>{/each}
{#each gearSlots as slot}{@const selection=data.loadout.gear[slot]}{@const item=gear(selection.itemId)}<article><small>{slot.toUpperCase()}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${brand(selection.brandId)?.name ?? 'NO BRAND'} · T${item.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span>{#if item}<div class="shared-item-facts"><b>{number.format(selection.health)} HEALTH</b>{#if item.base_armor_min !== null}<b>{number.format(selection.armor)} ARMOR</b>{/if}</div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</article>{/each}
</section>
<section>
<header><span>AGENT SYSTEMS</span><b>10 SLOTS</b></header>
{#each data.loadout.chips as selection, index}{@const item=chip(selection.setId)}<article><small>SKILL CHIP 0{index+1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? selection.quality : 'NOT CONFIGURED'}</span></article>{/each}
{#each data.loadout.chips as selection, index}{@const item=chip(selection.setId)}<article><small>SKILL CHIP 0{index+1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? selection.quality : 'NOT CONFIGURED'}</span>{#if item}<em>{rollLine([selection.primary, ...selection.secondary]) || 'ATTRIBUTES NOT CONFIGURED'}</em>{/if}</article>{/each}
{#if protocol(data.loadout.protocol.itemId)}{@const protocolItem=protocol(data.loadout.protocol.itemId)}<article><small>OS PROTOCOL</small><strong>{protocolItem.name}</strong><span>LEVEL {data.loadout.protocol.level} · {data.loadout.protocol.talentUpgrades}/5 UPGRADES</span></article>{:else}<article><small>OS PROTOCOL</small><strong>EMPTY SLOT</strong><span>NOT CONFIGURED</span></article>{/if}
</section>
</div>
+1 -1
View File
@@ -203,7 +203,7 @@
<svelte:head><title>Combat calculations — ISAC</title></svelte:head>
<div class="module-toolbar">
<div><p class="eyebrow">MODELED DAMAGE + EMPIRICAL CALIBRATION</p><h2>COMBAT CALCULATIONS</h2><p>Configure typed calculation stages, Attribute bindings, and field observations used by the loadout builder.</p></div>
<div><p class="eyebrow">MODELED DAMAGE + EMPIRICAL CALIBRATION</p><h2>COMBAT CALCULATIONS</h2><p>Configure typed calculation stages, Attribute bindings, and field observations for the planned advanced loadout model.</p></div>
<div class="calculation-actions"><button class="secondary" onclick={() => calibrationEditing = blankCalibration()}>ADD CALIBRATION CASE +</button><button class="primary" onclick={() => revisionEditing = revisionFrom(activeRevision)}>NEW REVISION +</button></div>
</div>
@@ -380,20 +380,20 @@
</section>
<section id="scenarios" class="reference-section">
<header><div><span>09</span><h3>BUILDER SCENARIOS</h3></div></header>
<header><div><span>09</span><h3>PLANNED BUILDER SCENARIOS</h3></div></header>
<div class="scenario-grid">
<article>
<strong>MAXIMUM POTENTIAL</strong>
<p>Uses each mechanic’s configured best-case values to calculate a ceiling. It applies peak stacks and maximum supported target or ally counts.</p>
<p>The planned mode uses each mechanic’s configured best-case values to calculate a ceiling. It applies peak stacks and maximum supported target or ally counts.</p>
</article>
<article>
<strong>CUSTOM SCENARIO</strong>
<p>Uses player-selected toggles, counts, percentages, and cadence inputs to model a specific combat situation.</p>
<p>The planned mode uses player-selected toggles, counts, percentages, and cadence inputs to model a specific combat situation.</p>
</article>
</div>
<aside class="contract">
<strong>REQUIRES ASSUMPTIONS</strong>
<span>ISAC withholds a branch when the mechanic needs an undefined scenario value. The author or builder must supply it before the runtime includes that contribution.</span>
<span>The runtime withholds a branch when the mechanic needs an undefined scenario value. An author or future scenario control must supply it before the runtime includes that contribution.</span>
</aside>
<aside class="contract protocol-contract">
<strong>OS PROTOCOL TALENT UPGRADES</strong>
+1 -1
View File
@@ -19,7 +19,7 @@
{ key: 'database', label: 'DATABASE', description: 'Public game-data registries and tactical lookup.' },
{ key: 'builds', label: 'BUILDS', description: 'Published loadout discovery and shared build views.' },
{ key: 'guides', label: 'GUIDES', description: 'Long-form field manuals and build walkthroughs.' },
{ key: 'builder', label: 'BUILDER', description: 'Loadout creation, calculation, and account saves.' }
{ key: 'builder', label: 'BUILDER', description: 'Loadout creation, known-stat totals, and account saves.' }
] as const;
const moderationModelOptions = [
{ value: 'omni-moderation-latest', label: 'LATEST' },