feat: defer advanced build calculations
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user