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
@@ -1,13 +1,9 @@
<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>;
};
import type { CalculationMode, CombatProfile, ConditionalEffectSelection, EffectMode } from '$lib/builder/loadout';
import type { ScenarioInputDefinition } from '$lib/calculations/mechanic';
export type ConditionalEffectState = ConditionalEffectSelection;
let {
id,
@@ -15,7 +11,9 @@
description,
facts,
appliedState,
scenarioInputs = [],
effectState = $bindable<ConditionalEffectState>(),
calculationMode = 'custom',
disabled = false
}: {
id: string;
@@ -23,7 +21,9 @@
description?: string;
facts: string[];
appliedState: string;
scenarioInputs?: ScenarioInputDefinition[];
effectState: ConditionalEffectState;
calculationMode?: CalculationMode;
disabled?: boolean;
} = $props();
@@ -42,8 +42,15 @@
effectState = { ...effectState, overrides: { ...effectState.overrides, [key]: value } };
}
function updateScenarioInput(key: string, value: number | boolean) {
effectState = {
...effectState,
inputs: { ...effectState.inputs, [key]: value }
};
}
function clearOverrides() {
effectState = { ...effectState, overrides: undefined };
effectState = { ...effectState, overrides: undefined, inputs: undefined };
}
</script>
@@ -59,20 +66,51 @@
<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'}
{#if calculationMode === 'maximum'}
<div class="maximum-effect">
<strong>FULL EFFECT APPLIED</strong>
<span>Maximum stacks · best-case uptime</span>
</div>
{:else}
<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}
{#if calculationMode === 'custom' && 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}
{#if effectState.overrides || effectState.inputs}<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)} />
<RangeValueControl label="Shots per burst" min={1} max={200} step={1} value={effectState.overrides?.shotsPerBurst ?? 30} onchange={(value) => updateOverride('shotsPerBurst', value)} />
<RangeValueControl label="Pause between bursts" min={0} max={15} step={0.1} value={effectState.overrides?.burstPauseSeconds ?? 0} onchange={(value) => updateOverride('burstPauseSeconds', value)} />
{#each scenarioInputs as input}
<div class="program-override">
{#if input.kind === 'toggle'}
<SegmentedChoice
legend={input.label}
name={`${id}-scenario-${input.key}`}
options={[{ value: 'false', label: 'OFF' }, { value: 'true', label: 'ON' }]}
value={String(effectState.inputs?.[input.key] ?? input.defaultValue ?? false)}
onchange={(values) => updateScenarioInput(input.key, values[0] === 'true')}
/>
{:else}
<RangeValueControl
label={input.label}
min={input.min ?? 0}
max={input.max ?? 100}
step={input.kind === 'slider' ? 1 : 0.1}
value={Number(effectState.inputs?.[input.key] ?? input.defaultValue ?? input.min ?? 0)}
onchange={(value) => updateScenarioInput(input.key, value)}
/>
{/if}
{#if input.description}<small>{input.description}</small>{/if}
</div>
{/each}
</div>
{/if}
{/if}
@@ -89,9 +127,12 @@
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)}
.maximum-effect{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm);padding:7px 8px;border:1px solid #74552a;background:#1d180e}
.maximum-effect strong,.maximum-effect span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.maximum-effect strong{color:var(--shd-orange)}.maximum-effect span{color:var(--shd-text-muted)}
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}}
.program-override{display:grid;gap:5px}.program-override small{color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
@media(max-width:620px){header{display:grid}.override-grid{grid-template-columns:1fr}.maximum-effect{align-items:start;flex-direction:column}button{min-height:44px}}
@media(prefers-reduced-motion:reduce){button{transition:none}}
</style>