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
@@ -0,0 +1,88 @@
<script lang="ts">
import PseudoScript from './PseudoScript.svelte';
let {
fullReferenceHref = '/isac/mechanic-reference',
buttonLabel = 'SCRIPT REFERENCE'
}: {
fullReferenceHref?: string;
buttonLabel?: string;
} = $props();
let dialog: HTMLDialogElement;
const example = [
'WHEN weapon_hit',
'IF same_target_hits >= 9 WITHIN 3s',
'ADD 1 STACK TO relentless',
'MAX 10 STACKS',
'REFRESH 10s ON STACK',
'APPLY weapon_damage += 3% PER STACK'
];
</script>
<button type="button" class="script-help-trigger" onclick={() => dialog.showModal()}>
<span aria-hidden="true">?</span>{buttonLabel}
</button>
<dialog bind:this={dialog} aria-labelledby="mechanic-language-help-title">
<div class="dialog-shell">
<header>
<div>
<p>ISAC SCRIPT LANGUAGE</p>
<h2 id="mechanic-language-help-title">How calculation scripts work</h2>
</div>
<button class="close" type="button" aria-label="Close script reference" onclick={() => dialog.close()}>×</button>
</header>
<div class="help-content">
<p>ISAC generates these scripts from structured controls. You review the script, while typed data remains the calculation source.</p>
<dl>
<div><dt>WHEN</dt><dd>The event that begins a mechanic branch.</dd></div>
<div><dt>IF</dt><dd>Conditions checked against the event, Agent, target, team, or encounter.</dd></div>
<div><dt>TRACK</dt><dd>A count, duration, scalar, mark, or stack pool shared by branches.</dd></div>
<div><dt>ADD / SET / CONSUME</dt><dd>How a matching branch changes tracked state.</dd></div>
<div><dt>APPLY</dt><dd>The Attribute or direct result and the phase when it takes effect.</dd></div>
<div><dt>UNTIL</dt><dd>The expiry, reset, or transfer policy for tracked state.</dd></div>
<div><dt>EVERY</dt><dd>An explicit periodic interval. ISAC does not infer a missing cadence.</dd></div>
<div><dt>PHASE</dt><dd>Whether an outcome runs before state changes, after them, or while state remains active.</dd></div>
</dl>
<PseudoScript title="MECHANIC EXAMPLE" lines={example} compact />
<section class="calculation-language">
<h3>Combat calculation statements</h3>
<p><code>TAKE</code>, <code>ADD</code>, <code>APPLY</code>, <code>WEIGHT</code>, and <code>RETURN</code> explain the ordered deterministic damage pipeline.</p>
</section>
</div>
<footer>
<a class="secondary" href={fullReferenceHref} onclick={() => dialog.close()}>OPEN FULL REFERENCE →</a>
<button class="primary" type="button" onclick={() => dialog.close()}>RETURN TO EDITOR</button>
</footer>
</div>
</dialog>
<style>
.script-help-trigger{display:inline-flex;align-items:center;gap:7px;min-height:40px;padding:0 10px;border:1px solid var(--shd-border);background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;cursor:pointer;transition:var(--shd-transition)}
.script-help-trigger>span{display:grid;place-items:center;width:18px;height:18px;border:1px solid currentColor;border-radius:50%;font-size:11px}
.script-help-trigger:hover,.script-help-trigger:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange)}
.script-help-trigger:active{transform:translateY(1px);filter:brightness(.88)}
.script-help-trigger:focus-visible,.close:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px}
dialog{width:min(780px,calc(100vw - 24px));max-height:calc(100vh - 24px);padding:0;border:1px solid var(--shd-border-strong);background:var(--shd-surface);color:var(--shd-text);box-shadow:10px 12px 34px rgb(0 0 0 / .48)}
dialog::backdrop{background:rgb(0 0 0 / .76)}
.dialog-shell{display:grid;max-height:calc(100vh - 26px)}
.dialog-shell>header{display:flex;align-items:start;justify-content:space-between;gap:var(--shd-gap-md);padding:var(--shd-gap-md);border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}
header p{margin:0 0 3px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.1px}
h2{margin:0;font-size:var(--shd-type-body);text-transform:uppercase}
.close{display:grid;place-items:center;width:40px;height:40px;border:1px solid var(--shd-border);background:transparent;color:var(--shd-text);font-size:24px;cursor:pointer}
.close:hover{border-color:var(--shd-orange);color:var(--shd-orange)}
.help-content{display:grid;gap:var(--shd-gap-md);padding:var(--shd-gap-md);overflow:auto}
.help-content>p,.calculation-language p{margin:0;color:var(--shd-text-muted);line-height:1.5}
dl{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1px;margin:0;background:var(--shd-border)}
dl>div{display:grid;grid-template-columns:100px 1fr;gap:8px;padding:9px;background:var(--shd-surface-raised)}
dt{color:var(--shd-success);font-family:var(--shd-font-mono);font-size:var(--shd-type-supporting);font-weight:900}
dd{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
.calculation-language{padding:10px;border:1px solid var(--shd-border)}
.calculation-language h3{margin:0 0 5px;font-size:var(--shd-type-body);text-transform:uppercase}
.calculation-language code{color:var(--shd-success);font-family:var(--shd-font-mono);font-weight:900}
footer{display:flex;justify-content:flex-end;gap:var(--shd-gap-sm);padding:var(--shd-gap-md);border-top:1px solid var(--shd-border);background:var(--shd-surface-raised)}
footer a,footer button{display:inline-flex;align-items:center;justify-content:center;min-height:40px;padding:0 14px}
@media(max-width:620px){.script-help-trigger{min-height:44px}dl{grid-template-columns:1fr}footer{display:grid}footer a,footer button{width:100%;min-height:44px}}
@media(prefers-reduced-motion:reduce){.script-help-trigger{transition:none}}
</style>
+139 -5
View File
@@ -105,6 +105,8 @@
const proposedEffects = $derived((jsonValue(proposed.effectsJson) ?? []) as any[]);
const proposedRanks = $derived((jsonValue(proposed.ranksJson) ?? []) as any[]);
const currentAbilityEffects = $derived((currentSnapshot?.effects ?? []) as any[]);
const currentAbilityAttack = $derived((currentSnapshot?.attack ?? null) as Record<string, unknown> | null);
const proposedAbilityAttack = $derived((jsonValue(proposed.attackJson) ?? null) as Record<string, unknown> | null);
const currentNodeRanks = $derived((currentSnapshot?.ranks ?? []) as any[]);
const currentNodeEffects = $derived((currentSnapshot?.effects ?? []) as any[]);
const currentNodeCosts = $derived((currentSnapshot?.costs ?? []) as any[]);
@@ -112,11 +114,129 @@
const calculationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const proposedCalculationStages = $derived((jsonValue(proposed.stagesJson) ?? []) as any[]);
const proposedCalculationProfiles = $derived((jsonValue(proposed.profilesJson) ?? []) as any[]);
const proposedSkillCalculationProfiles = $derived((jsonValue(proposed.skillProfilesJson) ?? []) as any[]);
const effectSummary = (effect: any) => {
const target = effect.statId || effect.stat_attribute_id || effect.abilityId || effect.ability_id || effect.label || 'CUSTOM EFFECT';
const value = effect.value === null || effect.value === undefined || effect.value === '' ? '' : `${effect.value}${effect.unit ? ` ${effect.unit}` : ''}`;
return `${target}${value ? ` · ${value}` : ''}`;
};
const conditionalRuleSummary = (effect: any) => {
const rule = effect.conditionalRule ?? {};
const clauses = Array.isArray(rule.clauses) ? rule.clauses : [];
const outcomes = clauses.reduce((total: number, clause: any) =>
total + (Array.isArray(clause.outcomes) ? clause.outcomes.length : 0), 0);
return `${effect.effectId} · ${readableToken(rule.simulationReadiness)} · ${clauses.length} BRANCH${clauses.length === 1 ? '' : 'ES'} · ${outcomes} OUTCOME${outcomes === 1 ? '' : 'S'}`;
};
const readableToken = (value: unknown) => shown(value).replaceAll('_', ' ').toUpperCase();
const storedJson = (value: unknown, fallback: unknown) => {
if (typeof value !== 'string') return value ?? fallback;
try { return JSON.parse(value); } catch { return fallback; }
};
const attackProfileLines = (attack: Record<string, unknown> | null) => {
if (!attack || ('enabled' in attack && !attack.enabled)) return [];
const delivery = String(attack.delivery ?? '').toLowerCase();
const baseDamage = attack.baseDamage ?? attack.base_damage;
const damageType = attack.damageType ?? attack.damage_type;
const hits = attack.hitsPerActivation ?? attack.hits_per_activation;
const castSeconds = attack.castSeconds ?? attack.cast_seconds;
const targetCap = attack.targetCap ?? attack.target_cap;
const chargeCount = attack.chargeCount ?? attack.charge_count;
const chargePolicy = attack.chargePolicy ?? attack.charge_policy;
const lines = [
`BASE DAMAGE · ${shown(baseDamage)} · ${readableToken(damageType)}`,
`DELIVERY · ${readableToken(delivery)} · ${shown(hits)} HIT${Number(hits) === 1 ? '' : 'S'} PER ACTIVATION · TARGET CAP ${shown(targetCap)}`,
`TIMING · ${shown(castSeconds)}s CAST`,
`CHARGES · ${shown(chargeCount)} · ${readableToken(chargePolicy)}`
];
if (delivery === 'periodic') {
const interval = attack.tickIntervalSeconds ?? attack.tick_interval_seconds;
const duration = attack.damageDurationSeconds ?? attack.damage_duration_seconds;
lines.splice(3, 0, `PERIODIC WINDOW · EVERY ${shown(interval)}s FOR ${shown(duration)}s`);
}
return lines;
};
const conditionalRuleLines = (source: unknown) => {
if (!source || typeof source !== 'object' || Array.isArray(source)) return [];
const wrapper = source as Record<string, any>;
const persisted = wrapper.rule && typeof wrapper.rule === 'object';
const rule = persisted ? wrapper.rule : wrapper;
const inputs = persisted
? storedJson(rule.scenario_inputs_json, [])
: rule.scenarioInputs;
const pools = persisted ? wrapper.statePools : rule.pools;
const clauses = persisted ? wrapper.clauses : rule.clauses;
const lines = [
`READINESS · ${readableToken(rule.simulationReadiness ?? rule.simulation_readiness)}`,
`MODEL · ${Array.isArray(inputs) ? inputs.length : 0} SCENARIO INPUTS · ${Array.isArray(pools) ? pools.length : 0} STATE POOLS · ${Array.isArray(clauses) ? clauses.length : 0} BRANCHES`
];
if (Array.isArray(inputs)) {
for (const input of inputs) {
lines.push(`INPUT · ${shown(input.label ?? input.key)} · ${readableToken(input.kind)} · ${readableToken(input.readiness ?? 'ready')}`);
}
}
if (Array.isArray(pools)) {
for (const pool of pools) {
const poolKey = pool.key ?? pool.state_key;
const scope = pool.scope ?? pool.state_scope;
const maximum = pool.max ?? pool.maximum_value;
const expiry = pool.expiresAfterSeconds ?? pool.duration_seconds;
lines.push(`STATE · ${shown(poolKey)} · ${readableToken(scope)} · MAX ${shown(maximum)}${expiry ? ` · EXPIRES ${expiry}s` : ''}`);
}
}
if (Array.isArray(clauses)) {
for (const [index, clause] of clauses.entries()) {
const event = clause.event ?? clause.event_kind;
const recipe = clause.recipeKey ?? clause.recipe_key;
const reducer = persisted
? storedJson(clause.reducer_json, { kind: 'none' })
: clause.reducer;
const mutations = persisted
? storedJson(clause.mutations_json, [])
: clause.mutations;
const branchOutcomes = persisted
? (wrapper.outcomes ?? []).filter((outcome: any) => outcome.clause_id === clause.id)
: clause.outcomes ?? [];
lines.push([
`BRANCH ${index + 1}`,
readableToken(event),
recipe ? `RECIPE ${readableToken(recipe)}` : '',
`REDUCER ${readableToken(reducer?.kind)}`,
clause.cooldownSeconds ?? clause.cooldown_seconds
? `${clause.cooldownSeconds ?? clause.cooldown_seconds}s COOLDOWN`
: '',
`${Array.isArray(mutations) ? mutations.length : 0} MUTATIONS`,
`${branchOutcomes.length} OUTCOMES`
].filter(Boolean).join(' · '));
for (const outcome of branchOutcomes) {
if (persisted) {
lines.push([
`↳ ${readableToken(outcome.variant_key)}`,
readableToken(outcome.application_kind),
readableToken(outcome.effect_scope),
outcome.stat_attribute_id || readableToken(outcome.resource_kind ?? outcome.value_basis),
`${shown(outcome.value)}${outcome.value_expression_json ? ' · EXPRESSION' : ''}`,
readableToken(outcome.application_phase)
].join(' · '));
continue;
}
const variants = outcome.variants && typeof outcome.variants === 'object'
? Object.entries(outcome.variants).map(([variant, configured]: [string, any]) =>
`${readableToken(variant)} ${shown(configured?.value)}${configured?.expression ? ' (EXPRESSION)' : ''}`
).join(' · ')
: 'NO VARIANTS';
lines.push([
`↳ ${shown(outcome.label ?? outcome.key)}`,
readableToken(outcome.kind),
readableToken(outcome.scope),
outcome.attributeId || readableToken(outcome.resource ?? outcome.basis),
variants,
readableToken(outcome.phase)
].join(' · '));
}
}
}
return lines;
};
</script>
<section class="proposal-change-preview" aria-label="Proposed data comparison">
@@ -168,7 +288,10 @@
<div class="proposal-field-summary">
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.current)}</strong></div>{/each}
<div><span>ROLL POOLS</span><strong>{(currentSnapshot?.primaryOptions as any[])?.length ?? 0} PRIMARY · {(currentSnapshot?.secondaryOptions as any[])?.length ?? 0} SECONDARY</strong></div>
<div><span>TALENT STRUCTURE</span><strong>{(currentSnapshot?.talentTriggers as any[])?.length ?? 0} TRIGGERS · {(currentSnapshot?.talentEffects as any[])?.length ?? 0} EFFECTS</strong></div>
</div>
<div class="proposal-structured-list">
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
{#each conditionalRuleLines(currentSnapshot?.conditional) as line}<p>{line}</p>{:else}<p>NO TYPED RULE CONFIGURED</p>{/each}
</div>
{:else}
<div class="proposal-empty-state"><b>NEW SKILL CHIP</b><span>No live registry record will be replaced.</span></div>
@@ -179,7 +302,10 @@
<div class="proposal-field-summary">
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.proposed)}</strong></div>{/each}
<div><span>ROLL POOLS</span><strong>{Array.isArray(proposed.primaryStatIds) ? proposed.primaryStatIds.length : Number(Boolean(proposed.primaryStatIds))} PRIMARY · {Array.isArray(proposed.secondaryStatIds) ? proposed.secondaryStatIds.length : Number(Boolean(proposed.secondaryStatIds))} SECONDARY</strong></div>
<div><span>TALENT STRUCTURE</span><strong>{Array.isArray(proposed.triggerIds) ? proposed.triggerIds.length : Number(Boolean(proposed.triggerIds))} TRIGGERS · {((jsonValue(proposed.talentEffects) ?? []) as any[]).length} EFFECTS</strong></div>
</div>
<div class="proposal-structured-list">
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
{#each conditionalRuleLines(jsonValue(proposed.conditionalRule)) as line}<p>{line}</p>{:else}<p>NO TYPED RULE CONFIGURED</p>{/each}
</div>
</article>
</div>
@@ -187,11 +313,11 @@
<div class="proposal-compare-grid calculation-proposal-preview">
<article class="proposal-editor-preview before">
<header><span>CURRENT LIVE · {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}</span><b>{calculationRecord ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
{#if calculationRecord}<div class="proposal-field-summary"><div><span>NAME</span><strong>{shown(calculationRecord.name)}</strong></div><div><span>STATE</span><strong>{shown(calculationRecord.status)}</strong></div>{#if action === 'saveRevision'}<div><span>STAGES</span><strong>{(currentSnapshot?.stages as any[])?.length ?? 0}</strong></div><div><span>ATTRIBUTE PROFILES</span><strong>{(currentSnapshot?.profiles as any[])?.length ?? 0}</strong></div>{/if}</div>{:else}<div class="proposal-empty-state"><b>NEW {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}</b><span>No live registry record will be replaced.</span></div>{/if}
{#if calculationRecord}<div class="proposal-field-summary"><div><span>NAME</span><strong>{shown(calculationRecord.name)}</strong></div><div><span>STATE</span><strong>{shown(calculationRecord.status)}</strong></div>{#if action === 'saveRevision'}<div><span>STAGES</span><strong>{(currentSnapshot?.stages as any[])?.length ?? 0}</strong></div><div><span>WEAPON PROFILES</span><strong>{(currentSnapshot?.profiles as any[])?.length ?? 0}</strong></div><div><span>SKILL PROFILES</span><strong>{(currentSnapshot?.skillProfiles as any[])?.length ?? 0}</strong></div>{/if}</div>{:else}<div class="proposal-empty-state"><b>NEW {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}</b><span>No live registry record will be replaced.</span></div>{/if}
</article>
<article class="proposal-editor-preview after">
<header><span>PROPOSED · {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}</span><b>AFTER</b></header>
<div class="proposal-field-summary"><div><span>NAME</span><strong>{shown(proposed.name)}</strong></div>{#if action === 'saveRevision'}<div><span>STAGES</span><strong>{proposedCalculationStages.length}</strong></div><div><span>ATTRIBUTE PROFILES</span><strong>{proposedCalculationProfiles.length}</strong></div><div><span>BASE REVISION</span><strong>{shown(proposed.basedOnRevisionId)}</strong></div>{:else}<div><span>STATE</span><strong>{shown(proposed.status)}</strong></div><div><span>MEASURED WEAPON</span><strong>{proposed.selectedWeapon === '1' ? 'SECONDARY' : 'PRIMARY'}</strong></div><div><span>CORE OBSERVATIONS</span><strong>{['bodyHit','criticalBodyHit','headshot','criticalHeadshot','magazineSeconds','reloadSeconds'].filter((field) => proposed[field]).length} / 6</strong></div>{/if}</div>
<div class="proposal-field-summary"><div><span>NAME</span><strong>{shown(proposed.name)}</strong></div>{#if action === 'saveRevision'}<div><span>STAGES</span><strong>{proposedCalculationStages.length}</strong></div><div><span>WEAPON PROFILES</span><strong>{proposedCalculationProfiles.length}</strong></div><div><span>SKILL PROFILES</span><strong>{proposedSkillCalculationProfiles.length}</strong></div><div><span>BASE REVISION</span><strong>{shown(proposed.basedOnRevisionId)}</strong></div>{:else}<div><span>STATE</span><strong>{shown(proposed.status)}</strong></div><div><span>MEASURED WEAPON</span><strong>{proposed.selectedWeapon === '1' ? 'SECONDARY' : 'PRIMARY'}</strong></div><div><span>CORE OBSERVATIONS</span><strong>{['bodyHit','criticalBodyHit','headshot','criticalHeadshot','magazineSeconds','reloadSeconds'].filter((field) => proposed[field]).length} / 6</strong></div>{/if}</div>
{#if action === 'saveRevision'}<div class="proposal-rank-preview">{#each proposedCalculationStages as stage}<section><header><span>{stage.label}</span><b>{stage.operator?.replaceAll('_',' ').toUpperCase()}</b></header><p>ORDER {stage.displayOrder} · {stage.lockedPosition?.toUpperCase() ?? 'REORDERABLE'}</p></section>{/each}</div>{/if}
</article>
</div>
@@ -210,6 +336,10 @@
<header><span>STRUCTURED EFFECTS</span><b>{currentAbilityEffects.length}</b></header>
{#each currentAbilityEffects as effect}<p>{effectSummary(effect)}</p>{:else}<p>NO STRUCTURED EFFECTS</p>{/each}
</div>
<div class="proposal-structured-list">
<header><span>INTRINSIC ATTACK PROFILE</span><b>{currentAbilityAttack ? 'CONFIGURED' : 'NONE'}</b></header>
{#each attackProfileLines(currentAbilityAttack) as line}<p>{line}</p>{:else}<p>NO INTRINSIC ATTACK PROFILE</p>{/each}
</div>
{:else if action === 'saveNode'}
<div class="proposal-rank-preview">
{#each currentNodeRanks as rank}
@@ -239,6 +369,10 @@
<header><span>STRUCTURED EFFECTS</span><b>{proposedEffects.length}</b></header>
{#each proposedEffects as effect}<p>{effectSummary(effect)}</p>{:else}<p>NO STRUCTURED EFFECTS</p>{/each}
</div>
<div class="proposal-structured-list">
<header><span>INTRINSIC ATTACK PROFILE</span><b>{attackProfileLines(proposedAbilityAttack).length ? 'CONFIGURED' : 'NONE'}</b></header>
{#each attackProfileLines(proposedAbilityAttack) as line}<p>{line}</p>{:else}<p>NO INTRINSIC ATTACK PROFILE</p>{/each}
</div>
{:else if action === 'saveNode'}
<div class="proposal-rank-preview">
{#each proposedRanks as rank}
@@ -246,7 +380,7 @@
<header><span>RANK {rank.rank}</span><b>{rank.effects?.length ?? 0} STATIC · {rank.combatEffects?.length ?? 0} CONDITIONAL</b></header>
<p>{rank.description || 'NO RANK DESCRIPTION'}</p>
{#each rank.effects ?? [] as effect}<small>{effectSummary(effect)}</small>{/each}
{#each rank.combatEffects ?? [] as effect}<small>{effect.effectId} · {effect.triggerCount} HITS / {effect.triggerWindowSeconds}s · {effect.maxStacks} STACKS · {effect.modifiers?.length ?? 0} MODIFIERS</small>{/each}
{#each rank.combatEffects ?? [] as effect}<small>{conditionalRuleSummary(effect)}</small>{/each}
<small>{Object.entries(rank.costs ?? {}).filter(([, quantity]) => Number(quantity) > 0).map(([resource, quantity]) => `${resource} × ${quantity}`).join(' · ') || 'CREDITS ONLY'}</small>
</section>
{/each}
+54
View File
@@ -0,0 +1,54 @@
<script lang="ts">
let {
title = 'GENERATED SCRIPT',
lines,
label = title,
compact = false,
readiness = 'READY'
}: {
title?: string;
lines: string[];
label?: string;
compact?: boolean;
readiness?: 'READY' | 'REQUIRES ASSUMPTIONS' | 'CAPTURED ONLY';
} = $props();
const keyword = (line: string) => line.trim().split(/\s+/, 1)[0] ?? '';
const remainder = (line: string) => line.trim().slice(keyword(line).length).trimStart();
</script>
<section class:compact class="pseudo-script" aria-label={label}>
<header>
<span aria-hidden="true">▤</span>
<strong>{title}</strong>
<small>{readiness} · READ-ONLY · GENERATED FROM TYPED DATA</small>
</header>
<ol>
{#each lines as line}
<li class:blank={!line.trim()}>
{#if line.trim()}
<code><b>{keyword(line)}</b>{#if remainder(line)}{' '}<span>{remainder(line)}</span>{/if}</code>
{:else}
<span aria-hidden="true">&nbsp;</span>
{/if}
</li>
{/each}
</ol>
</section>
<style>
.pseudo-script{min-width:0;border:1px solid var(--shd-model-border);border-left:2px solid var(--shd-info);background:var(--shd-model-surface)}
header{display:flex;align-items:center;gap:7px;min-height:38px;padding:6px 9px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}
header>span{color:var(--shd-success);font-size:var(--shd-type-body)}
header strong{color:var(--shd-text);font-size:var(--shd-type-telemetry);letter-spacing:.9px}
header small{margin-left:auto;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
ol{display:grid;gap:2px;margin:0;padding:8px 9px 9px 3.4rem;counter-reset:script-line;color:var(--shd-text-dim)}
li{min-height:1.45em;padding-left:8px;border-left:1px solid var(--shd-border-soft);font-family:var(--shd-font-mono);font-size:var(--shd-type-supporting);line-height:1.45}
li::marker{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-variant-numeric:tabular-nums}
li.blank{min-height:.55em}
code{font:inherit;color:var(--shd-text);white-space:pre-wrap;overflow-wrap:anywhere}
code b{color:var(--shd-success);font-weight:900}
code span{color:var(--shd-text)}
.compact header small{display:none}.compact ol{max-height:260px;overflow:auto}
@media(max-width:620px){header{align-items:flex-start;flex-wrap:wrap}header small{flex-basis:100%;margin-left:24px}ol{padding-left:2.8rem}li{font-size:var(--shd-type-telemetry)}}
</style>
@@ -1,41 +1,92 @@
<script lang="ts">
import RangeValueControl from '$lib/components/RangeValueControl.svelte';
export type CombatProfile = {
encounterSeconds: number;
hitRate: number;
headshotRate: number;
firingActivity: number;
};
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import type { CalculationMode, CombatProfile } from '$lib/builder/loadout';
export type { CalculationMode, CombatProfile } from '$lib/builder/loadout';
let {
mode = $bindable<CalculationMode>(),
profile = $bindable<CombatProfile>(),
disabled = false
}: {
mode: CalculationMode;
profile: CombatProfile;
disabled?: boolean;
} = $props();
const modes = [
{ value: 'maximum', label: 'MAXIMUM POTENTIAL' },
{ value: 'custom', label: 'CUSTOM SCENARIO' }
];
function setMode(values: string[]) {
mode = values[0] as CalculationMode;
}
function update(key: keyof CombatProfile, value: number) {
profile = { ...profile, [key]: value };
}
function setExtraHealth(values: string[]) {
profile = { ...profile, extraHealthActive: values[0] === 'active' };
}
</script>
<section class="combat-assumptions" aria-labelledby="combat-assumptions-title">
<div class="section-head">
<div>
<p class="eyebrow">COMBAT PROFILE</p>
<h3 id="combat-assumptions-title">Encounter assumptions</h3>
<p class="eyebrow">CALCULATION MODEL</p>
<h3 id="combat-assumptions-title">Combat conditions</h3>
</div>
<small>MODELED, NOT LIVE COMBAT</small>
<small>{mode === 'maximum' ? 'BEST-CASE OUTPUT' : 'MODELED SCENARIO'}</small>
</div>
<p class="supporting">These assumptions drive encounter-average calculations. Individual effects can override them when needed.</p>
<fieldset class="assumption-grid" disabled={disabled}>
<RangeValueControl label="Encounter duration" min={10} max={300} step={5} value={profile.encounterSeconds} onchange={(value) => update('encounterSeconds', value)} />
<RangeValueControl label="Hit rate" min={0} max={100} value={profile.hitRate} onchange={(value) => update('hitRate', value)} />
<RangeValueControl label="Headshot rate" min={0} max={100} value={profile.headshotRate} onchange={(value) => update('headshotRate', value)} />
<RangeValueControl label="Firing activity" min={0} max={100} value={profile.firingActivity} onchange={(value) => update('firingActivity', value)} />
<p class="supporting">{mode === 'maximum' ? 'Calculates best-case output with every configured effect at maximum stacks and selected skills used on cooldown.' : 'Uses your encounter assumptions and individual effect settings to estimate a repeatable combat scenario.'}</p>
<fieldset class="model-control" disabled={disabled}>
<SegmentedChoice
legend="DAMAGE CALCULATION MODEL"
name="builder-calculation-model"
options={modes}
value={mode}
columns={2}
onchange={setMode}
required
/>
</fieldset>
{#if mode === 'custom'}
<div class="custom-scenario" id="custom-combat-scenario">
<div class="custom-head">
<strong>CUSTOM SCENARIO INPUTS</strong>
<span>Saved with this loadout</span>
</div>
<fieldset class="assumption-grid" disabled={disabled}>
<RangeValueControl label="Encounter duration" min={10} max={300} step={5} value={profile.encounterSeconds} onchange={(value) => update('encounterSeconds', value)} />
<RangeValueControl label="Hit rate" min={0} max={100} value={profile.hitRate} onchange={(value) => update('hitRate', value)} />
<RangeValueControl label="Headshot rate" min={0} max={100} value={profile.headshotRate} onchange={(value) => update('headshotRate', value)} />
<RangeValueControl label="Shots per burst" min={1} max={200} step={1} value={profile.shotsPerBurst} onchange={(value) => update('shotsPerBurst', value)} />
<RangeValueControl label="Pause between bursts" min={0} max={15} step={0.1} value={profile.burstPauseSeconds} onchange={(value) => update('burstPauseSeconds', value)} />
<RangeValueControl label="Time on the same target" min={0.5} max={60} step={0.5} value={profile.sameTargetSeconds} onchange={(value) => update('sameTargetSeconds', value)} />
<RangeValueControl label="Distinct targets per minute" min={0} max={120} step={1} value={profile.distinctTargetsPerMinute} onchange={(value) => update('distinctTargetsPerMinute', value)} />
<RangeValueControl label="Kills per minute" min={0} max={60} step={1} value={profile.killsPerMinute} onchange={(value) => update('killsPerMinute', value)} />
<RangeValueControl label="Headshot kill share" min={0} max={100} step={1} value={profile.headshotKillRate} onchange={(value) => update('headshotKillRate', value)} />
<RangeValueControl label="Damage events per minute" min={0} max={120} step={1} value={profile.damageTakenPerMinute} onchange={(value) => update('damageTakenPerMinute', value)} />
<RangeValueControl label="Weapon swaps per minute" min={0} max={30} step={1} value={profile.swapsPerMinute} onchange={(value) => update('swapsPerMinute', value)} />
<RangeValueControl label="Target distance" min={0} max={100} step={1} value={profile.targetDistanceMeters} onchange={(value) => update('targetDistanceMeters', value)} />
<RangeValueControl label="Agent health" min={1} max={100} step={1} value={profile.healthPercent} onchange={(value) => update('healthPercent', value)} />
<SegmentedChoice
legend="EXTRA HEALTH"
name="combat-extra-health"
options={[{ value: 'inactive', label: 'INACTIVE' }, { value: 'active', label: 'ACTIVE' }]}
value={profile.extraHealthActive ? 'active' : 'inactive'}
onchange={setExtraHealth}
/>
</fieldset>
</div>
{:else}
<div class="maximum-model" role="status">
<strong>MAXIMUM POTENTIAL ACTIVE</strong>
<span>Effect ramp-up and encounter downtime are intentionally excluded.</span>
</div>
{/if}
</section>
<style>
@@ -45,6 +96,15 @@
h3{margin:0;color:var(--shd-text);font-size:var(--shd-type-body);text-transform:uppercase;letter-spacing:.7px}
.section-head small{padding:4px 6px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;white-space:nowrap}
.supporting{margin:var(--shd-gap-sm) 0 var(--shd-gap-md);color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}
.model-control{margin:0;padding:0;border:0}
.custom-scenario{display:grid;gap:var(--shd-gap-md);margin-top:var(--shd-gap-md);padding-top:var(--shd-gap-md);border-top:1px solid var(--shd-border-soft)}
.custom-head{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm)}
.custom-head strong,.custom-head span,.maximum-model strong,.maximum-model span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
.custom-head strong,.maximum-model strong{color:var(--shd-text)}
.custom-head span,.maximum-model span{color:var(--shd-text-muted)}
.assumption-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--shd-gap-md);margin:0;padding:0;border:0}.assumption-grid:disabled{opacity:.55}
.maximum-model{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-md);margin-top:var(--shd-gap-md);padding:var(--shd-gap-sm);border:1px solid #74552a;background:#1d180e}
.maximum-model strong{color:var(--shd-orange)}
@media(max-width:620px){.section-head{display:grid}.section-head small{width:max-content}.assumption-grid{grid-template-columns:1fr}}
@media(max-width:620px){.custom-head,.maximum-model{align-items:start;flex-direction:column}}
</style>
@@ -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>
@@ -0,0 +1,57 @@
<script lang="ts">
import type { SkillCalculation } from '$lib/calculations/skill';
let {
result,
abilityNames = {}
}: {
result: SkillCalculation;
abilityNames?: Record<string, string>;
} = $props();
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });
</script>
<article class:incomplete={result.status === 'INSUFFICIENT_DATA'} class="skill-calculation" aria-live="polite">
<header>
<div><p>SKILL OUTPUT</p><h3>Specialization abilities</h3></div>
<b>{result.status === 'OK' ? 'CALCULATED' : 'INSUFFICIENT DATA'}</b>
</header>
{#if result.status === 'INSUFFICIENT_DATA'}
<div class="missing">
<strong>Skill calculation unavailable</strong>
<span>ISAC requires the following data:</span>
<ul>{#each result.missing as item}<li>{item.replaceAll(':', ' · ')}</li>{/each}</ul>
</div>
{:else}
<div class="combined">
<span><small>SINGLE-TARGET DPS</small><b>{number.format(result.combined.singleTargetDps)}</b></span>
<span><small>ALL-TARGET DPS</small><b>{number.format(result.combined.aggregateDps)}</b></span>
<span><small>ENCOUNTER DAMAGE</small><b>{number.format(result.combined.aggregateEncounterDamage)}</b></span>
</div>
<div class="ability-results">
{#each result.abilities as ability}
<div class:disabled={!ability.enabled}>
<strong>{abilityNames[ability.abilityId] ?? ability.abilityId}</strong>
<span>{ability.enabled ? `${ability.activationCount} ACTIVATIONS` : 'OFF'}</span>
<small>{number.format(ability.damage.singleTargetDps)} SINGLE DPS</small>
<small>{number.format(ability.damage.aggregateDps)} TOTAL DPS</small>
</div>
{/each}
</div>
{/if}
<footer>SKILLS CAN CRIT AND MULTI-SHOT · SKILLS DO NOT HEADSHOT · ENCOUNTER TRIGGERS FOLLOW THE PRIMARY WEAPON TIMELINE</footer>
</article>
<style>
.skill-calculation{display:grid;gap:var(--shd-gap-md);padding:var(--shd-gap-md);border:1px solid var(--shd-border);border-top:2px solid var(--shd-info);background:var(--shd-surface)}
.skill-calculation.incomplete{border-top-color:var(--shd-danger)}
header{display:flex;justify-content:space-between;align-items:start;gap:var(--shd-gap-md)}
header p,h3{margin:0}header p{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.1px}h3{text-transform:uppercase;font-size:var(--shd-type-body);letter-spacing:.7px}
header>b{padding:5px 7px;border:1px solid var(--shd-info);color:var(--shd-info);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.incomplete header>b{border-color:var(--shd-danger);color:var(--shd-danger)}
.combined{display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--shd-border)}.combined span{display:grid;gap:4px;padding:9px;border-left:1px solid var(--shd-border)}.combined span:first-child{border-left:0}.combined b{color:var(--shd-info);font-size:20px}
small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
.ability-results{display:grid;border:1px solid var(--shd-border)}.ability-results>div{display:grid;grid-template-columns:minmax(140px,1fr) repeat(3,auto);gap:var(--shd-gap-sm);padding:7px 8px;border-top:1px solid var(--shd-border);align-items:center}.ability-results>div:first-child{border-top:0}.ability-results>div.disabled{opacity:.55}.ability-results span{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}
.missing{display:grid;gap:4px;padding:10px;border:1px solid var(--shd-danger);border-left-width:2px;background:#1d1212;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.missing strong{color:var(--shd-danger);text-transform:uppercase}.missing ul{margin:2px 0 0;padding-left:18px}
footer{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:.6px}
@media(max-width:700px){header,.combined{display:grid;grid-template-columns:1fr}.combined span{border-left:0;border-top:1px solid var(--shd-border)}.combined span:first-child{border-top:0}.ability-results>div{grid-template-columns:1fr 1fr}.ability-results strong{grid-column:1/-1}}
</style>
@@ -20,6 +20,9 @@
stacksAtEnd: number;
buildUpSeconds: number | null;
maintained: boolean;
uptimePercent?: number;
activationCount?: number;
instantTotal?: number;
}>;
missing?: string[];
revisionLabel?: string;
@@ -52,6 +55,7 @@
<span>{effect.mode.toUpperCase()}</span>
<small>AVG {effect.averageStacks.toFixed(1)} / {effect.maxStacks}</small>
<small>PEAK {effect.maxStacksReached} · END {effect.stacksAtEnd}</small>
<small>{(effect.uptimePercent ?? 0).toFixed(0)}% UPTIME · {effect.activationCount ?? 0} PROCS</small>
<em>{effect.mode === 'off' ? 'EXCLUDED' : effect.buildUpSeconds === null ? 'MAX NOT REACHED' : `MAX IN ${effect.buildUpSeconds.toFixed(1)}s`}</em>
<b class:maintained={effect.maintained}>{effect.maintained ? 'MAINTAINED' : 'NOT MAINTAINED'}</b>
</div>
@@ -66,7 +70,7 @@
.weapon-calculation{display:grid;gap:var(--shd-gap-md);padding:var(--shd-gap-md);border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-surface)}
.weapon-calculation.incomplete{border-top-color:var(--shd-danger)}header{display:flex;justify-content:space-between;gap:var(--shd-gap-md);align-items:start}header p{margin:0 0 3px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.1px}h3{margin:0;font-size:var(--shd-type-body);letter-spacing:.7px;text-transform:uppercase}header>b{padding:5px 7px;border:1px solid #74552a;background:#1d180e;color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px;white-space:nowrap}.incomplete header>b{border-color:#563734;background:#1d1212;color:var(--shd-danger)}
.damage-grid,.dps-grid{display:grid;border:1px solid var(--shd-border)}.damage-grid{grid-template-columns:repeat(2,1fr)}.dps-grid{grid-template-columns:repeat(3,1fr)}.damage-grid span,.dps-grid span{display:grid;gap:4px;padding:9px;border-left:1px solid var(--shd-border);border-top:1px solid var(--shd-border)}.damage-grid span:nth-child(odd),.dps-grid span:first-child{border-left:0}.damage-grid span:nth-child(-n+2){border-top:0}.dps-grid b{color:var(--shd-orange);font-size:20px}.damage-grid b{font-size:var(--shd-type-body)}small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.missing{display:grid;gap:4px;padding:10px;border:1px solid #563734;border-left:2px solid var(--shd-danger);background:#1d1212;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.missing strong{color:var(--shd-danger);text-transform:uppercase;font-size:var(--shd-type-telemetry);letter-spacing:.8px}.missing ul{margin:2px 0 0;padding-left:18px}.missing li+li{margin-top:2px}footer{display:flex;justify-content:space-between;gap:var(--shd-gap-sm);color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:.6px}
.stack-telemetry{display:grid;border:1px solid var(--shd-model-border);background:var(--shd-model-surface)}.stack-telemetry>header{display:flex;align-items:center;justify-content:space-between;padding:6px 8px;border-bottom:1px solid var(--shd-model-border);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.stack-telemetry>header span{color:var(--shd-info)}.stack-telemetry>header b{color:var(--shd-text-muted)}.stack-telemetry>div{display:grid;grid-template-columns:minmax(120px,1.2fr) .45fr .7fr .8fr .85fr .8fr;gap:7px;align-items:center;padding:7px 8px;border-top:1px solid var(--shd-border-soft);font-size:var(--shd-type-telemetry)}.stack-telemetry>div:first-of-type{border-top:0}.stack-telemetry>div>span,.stack-telemetry>div>small,.stack-telemetry>div>em,.stack-telemetry>div>b{font-size:inherit;font-style:normal;letter-spacing:.5px}.stack-telemetry>div>span{color:var(--shd-orange);font-weight:900}.stack-telemetry>div>em{color:var(--shd-text-muted)}.stack-telemetry>div>b{color:var(--shd-danger);text-align:right}.stack-telemetry>div>b.maintained{color:var(--shd-success)}
.stack-telemetry{display:grid;border:1px solid var(--shd-model-border);background:var(--shd-model-surface)}.stack-telemetry>header{display:flex;align-items:center;justify-content:space-between;padding:6px 8px;border-bottom:1px solid var(--shd-model-border);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.stack-telemetry>header span{color:var(--shd-info)}.stack-telemetry>header b{color:var(--shd-text-muted)}.stack-telemetry>div{display:grid;grid-template-columns:minmax(120px,1.2fr) .45fr .7fr .8fr .9fr .85fr .8fr;gap:7px;align-items:center;padding:7px 8px;border-top:1px solid var(--shd-border-soft);font-size:var(--shd-type-telemetry)}.stack-telemetry>div:first-of-type{border-top:0}.stack-telemetry>div>span,.stack-telemetry>div>small,.stack-telemetry>div>em,.stack-telemetry>div>b{font-size:inherit;font-style:normal;letter-spacing:.5px}.stack-telemetry>div>span{color:var(--shd-orange);font-weight:900}.stack-telemetry>div>em{color:var(--shd-text-muted)}.stack-telemetry>div>b{color:var(--shd-danger);text-align:right}.stack-telemetry>div>b.maintained{color:var(--shd-success)}
@media(max-width:820px){.stack-telemetry>div{grid-template-columns:1fr 1fr}.stack-telemetry>div>strong{grid-column:1/-1}.stack-telemetry>div>b{text-align:left}}
@media(max-width:620px){header{display:grid}.dps-grid{grid-template-columns:1fr}.dps-grid span{border-left:0;border-top:1px solid var(--shd-border)}.dps-grid span:first-child{border-top:0}footer{display:grid}.damage-grid span{min-height:44px}}
</style>
@@ -0,0 +1,372 @@
<script lang="ts">
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
type RegistryItem = {
id: string;
name: string;
unit?: string;
pending?: boolean;
proposerName?: string;
};
export type ConditionalApplication =
| 'active_modifier'
| 'direct_damage'
| 'restore_health'
| 'restore_armor'
| 'restore_ammo';
export type ConditionalScope =
| 'self'
| 'equipped_weapon'
| 'target'
| 'allies'
| 'self_and_allies'
| 'assigned_ability'
| 'all_skills';
export type ConditionalOutcomeDraft = {
id?: string;
application: ConditionalApplication;
scope: ConditionalScope;
statId: string;
damageBasis: 'flat' | 'weapon_base_percent';
values: Record<string, number | string | null | undefined>;
};
export type ConditionalValueColumn = {
key: string;
label: string;
required?: boolean;
};
let {
outcomes = $bindable([]),
attributes,
valueColumns = [
{ key: 'tierI', label: 'TIER I', required: true },
{ key: 'tierII', label: 'TIER II', required: true }
],
idPrefix = 'conditional-outcome',
addLabel = 'ADD OUTCOME',
maxOutcomes = 12,
allowAssignedAbility = false
}: {
outcomes?: ConditionalOutcomeDraft[];
attributes: RegistryItem[];
valueColumns?: ConditionalValueColumn[];
idPrefix?: string;
addLabel?: string;
maxOutcomes?: number;
allowAssignedAbility?: boolean;
} = $props();
const applicationOptions = [
{ value: 'active_modifier', label: 'ATTRIBUTE' },
{ value: 'direct_damage', label: 'DIRECT DAMAGE' },
{ value: 'restore_health', label: 'RESTORE HEALTH' },
{ value: 'restore_armor', label: 'RESTORE ARMOR' },
{ value: 'restore_ammo', label: 'RESTORE AMMO' }
];
const scopeOptions = $derived([
{ id: 'self', name: 'Self' },
{ id: 'equipped_weapon', name: 'Equipped weapon' },
{ id: 'target', name: 'Target' },
{ id: 'allies', name: 'Allies' },
{ id: 'self_and_allies', name: 'Self and allies' },
...(allowAssignedAbility ? [{ id: 'assigned_ability', name: 'Assigned ability' }] : []),
{ id: 'all_skills', name: 'All skills' }
]);
const damageBasisOptions = [
{ value: 'flat', label: 'FLAT' },
{ value: 'weapon_base_percent', label: '% WEAPON BASE' }
];
function blankOutcome(): ConditionalOutcomeDraft {
return {
application: 'active_modifier',
scope: 'self',
statId: '',
damageBasis: 'flat',
values: Object.fromEntries(valueColumns.map((column) => [column.key, '']))
};
}
function setApplication(index: number, application: string) {
const current = outcomes[index];
outcomes = outcomes.map((outcome, outcomeIndex) =>
outcomeIndex === index
? {
...current,
application: application as ConditionalApplication,
statId: application === 'active_modifier' ? current.statId : '',
damageBasis:
application === 'direct_damage' ? current.damageBasis || 'flat' : 'flat'
}
: outcome
);
}
function setValue(index: number, key: string, rawValue: string) {
const parsed = rawValue === '' ? '' : Number(rawValue);
outcomes = outcomes.map((outcome, outcomeIndex) =>
outcomeIndex === index
? { ...outcome, values: { ...outcome.values, [key]: parsed } }
: outcome
);
}
</script>
<div class="conditional-outcome-editor">
{#each outcomes as outcome, index}
<section class="outcome-row" aria-labelledby={`${idPrefix}-${index}-title`}>
<header>
<strong id={`${idPrefix}-${index}-title`}>OUTCOME {String(index + 1).padStart(2, '0')}</strong>
<button
type="button"
class="remove-outcome"
aria-label={`Remove outcome ${index + 1}`}
onclick={() => (outcomes = outcomes.filter((_, outcomeIndex) => outcomeIndex !== index))}
>
REMOVE
</button>
</header>
<div class="outcome-grid">
<SegmentedChoice
legend="APPLICATION"
name={`${idPrefix}-${index}-application`}
options={applicationOptions}
value={outcome.application}
onchange={(values) => setApplication(index, values[0])}
required
/>
<div class="field assignment-field">
<span>APPLIES TO</span>
<SearchableAssignment
items={scopeOptions}
mode="single"
compact
subjectLabel="EFFECT SCOPE"
searchPlaceholder="Search effect scopes…"
emptySelectionLabel="NO EFFECT SCOPE SELECTED"
bind:value={outcome.scope}
/>
</div>
{#if outcome.application === 'active_modifier'}
<div class="field assignment-field">
<span>ATTRIBUTE</span>
<SearchableAssignment
items={attributes}
mode="single"
compact
subjectLabel="ATTRIBUTE"
searchPlaceholder="Search Attributes…"
emptySelectionLabel="NO ATTRIBUTE SELECTED"
bind:value={outcome.statId}
/>
</div>
{:else if outcome.application === 'direct_damage'}
<SegmentedChoice
legend="DAMAGE BASIS"
name={`${idPrefix}-${index}-damage-basis`}
options={damageBasisOptions}
bind:value={outcome.damageBasis}
required
/>
{:else}
<div class="event-note">
<strong>INSTANT EVENT</strong>
<span>Tracked per proc and across the encounter. It does not become a permanent Attribute.</span>
</div>
{/if}
<div class="outcome-values" style={`--outcome-value-columns:${Math.max(valueColumns.length, 1)}`}>
{#each valueColumns as column}
<label class="field">
{column.label} VALUE
<input
type="number"
step="any"
value={outcome.values[column.key] ?? ''}
required={column.required ?? false}
aria-label={`${column.label} value for outcome ${index + 1}`}
oninput={(event) => setValue(index, column.key, event.currentTarget.value)}
/>
</label>
{/each}
</div>
</div>
</section>
{:else}
<div class="outcome-empty">
<strong>NO CALCULATION OUTCOMES</strong>
<span>Add at least one outcome before this conditional mechanic can affect a build.</span>
</div>
{/each}
<button
type="button"
class="secondary add-outcome"
disabled={outcomes.length >= maxOutcomes}
onclick={() => (outcomes = [...outcomes, blankOutcome()])}
>
{addLabel} +
</button>
</div>
<style>
.conditional-outcome-editor {
display: grid;
gap: var(--shd-gap-sm);
}
.outcome-row {
border: 1px solid var(--shd-border);
background: var(--shd-surface);
}
.outcome-row > header {
min-height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--shd-gap-sm);
padding: 5px 8px;
border-bottom: 1px solid var(--shd-border);
background: var(--shd-surface-raised);
}
.outcome-row > header strong {
color: var(--shd-orange);
font-size: var(--shd-type-telemetry);
letter-spacing: 1px;
}
.remove-outcome {
min-height: 28px;
padding: 0 7px;
border: 1px solid transparent;
background: transparent;
color: var(--shd-danger);
font-size: var(--shd-type-telemetry);
font-weight: 900;
cursor: pointer;
transition: var(--shd-transition);
}
.remove-outcome:hover,
.remove-outcome:focus-visible {
border-color: var(--shd-danger);
}
.remove-outcome:active {
transform: translateY(1px);
}
.remove-outcome:focus-visible,
.add-outcome:focus-visible {
outline: 2px solid var(--shd-focus-ring);
outline-offset: 2px;
}
.outcome-grid {
display: grid;
grid-template-columns: minmax(280px, 1.1fr) minmax(220px, 0.8fr);
align-items: end;
gap: var(--shd-gap-sm);
padding: var(--shd-gap-sm);
}
.outcome-grid > :global(.segmented-choice),
.assignment-field {
margin: 0;
}
.outcome-grid > :global(.segmented-choice:first-child),
.event-note,
.outcome-values {
grid-column: 1 / -1;
}
.assignment-field > span {
display: block;
margin-bottom: 5px;
font-weight: 900;
}
.event-note,
.outcome-empty {
display: grid;
gap: 3px;
padding: 9px;
border: 1px solid var(--shd-border-soft);
background: var(--shd-surface-raised);
}
.event-note strong,
.outcome-empty strong {
color: var(--shd-text);
font-size: var(--shd-type-telemetry);
letter-spacing: 0.8px;
}
.event-note span,
.outcome-empty span {
color: var(--shd-text-muted);
font-size: var(--shd-type-supporting);
line-height: 1.4;
}
.outcome-values {
display: grid;
grid-template-columns: repeat(var(--outcome-value-columns), minmax(100px, 1fr));
gap: var(--shd-gap-sm);
}
.outcome-values .field {
margin: 0;
}
.outcome-empty {
border-left: 2px solid var(--shd-orange);
}
.add-outcome {
width: max-content;
min-height: 40px;
}
.add-outcome:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@media (max-width: 760px) {
.outcome-grid {
grid-template-columns: 1fr;
}
.outcome-grid > * {
grid-column: 1;
}
.outcome-values {
grid-template-columns: 1fr;
}
.add-outcome {
width: 100%;
min-height: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
.remove-outcome {
transition: none;
}
}
</style>
@@ -0,0 +1,735 @@
<script lang="ts">
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import MechanicLanguageHelp from '$lib/components/MechanicLanguageHelp.svelte';
import PseudoScript from '$lib/components/PseudoScript.svelte';
import {
blankConditionalMechanicDraft,
parseConditionalMechanicDraft,
type ConditionalMechanicClauseDraft,
type ConditionalMechanicDraftV1,
type ConditionalMechanicOutcomeDraft,
type ConditionalMechanicPoolDraft
} from '$lib/calculations/mechanic-editor';
import {
formatMechanicProgram,
MECHANIC_EVENT_KINDS,
MECHANIC_EVENT_REGISTRY,
type ClauseMutation,
type MechanicOutcomeScope,
type NumericExpression,
type OutcomeBasis,
type OutcomePhase,
type Reducer,
type ScenarioInputDefinition
} from '$lib/calculations/mechanic';
import {
TRIGGER_ACTION_KEYS,
TRIGGER_ACTION_REGISTRY,
type TriggerActionKey
} from '$lib/calculations/conditional';
import { createMechanicRecipe } from '$lib/calculations/mechanic-recipes';
export type MechanicValueColumn = { key: string; variantKey: string; label: string };
type Attribute = { id: string; name: string; unit?: string };
let {
rule = $bindable(blankConditionalMechanicDraft()),
attributes,
valueColumns,
idPrefix = 'mechanic',
allowAssignedAbility = false,
allowProtocolUpgradeScaling = false,
disabled = false
}: {
rule?: ConditionalMechanicDraftV1;
attributes: Attribute[];
valueColumns: MechanicValueColumn[];
idPrefix?: string;
allowAssignedAbility?: boolean;
allowProtocolUpgradeScaling?: boolean;
disabled?: boolean;
} = $props();
let recipeKey = $state<TriggerActionKey>('same_target_hits_within_window');
let recipeCount = $state(9);
let recipeWindow = $state(3);
let recipeDistance = $state(10);
let replaceConfirmation = $state(false);
const readinessOptions = [
{ value: 'ready', label: 'READY' },
{ value: 'requires_assumptions', label: 'ASSUMPTIONS' },
{ value: 'captured_only', label: 'CAPTURE ONLY' }
];
const eventItems = MECHANIC_EVENT_KINDS.map((id) => ({
id,
name: MECHANIC_EVENT_REGISTRY[id].label
}));
const reducerItems = [
{ id: 'none', name: 'Every matching event' },
{ id: 'count', name: 'Count matches' },
{ id: 'distinct_count', name: 'Count distinct targets' },
{ id: 'consecutive_count', name: 'Count consecutive matches' },
{ id: 'sliding_count', name: 'Count within a time window' },
{ id: 'duration', name: 'Condition held for a duration' },
{ id: 'periodic', name: 'Repeat on an interval' },
{ id: 'derived_scalar', name: 'Derive a scalar value' }
];
const recipeItems = TRIGGER_ACTION_KEYS.map((id) => ({
id,
name: TRIGGER_ACTION_REGISTRY[id].label
}));
const outcomeKinds = [
{ value: 'attribute_modifier', label: 'ATTRIBUTE' },
{ value: 'direct_damage', label: 'DAMAGE' },
{ value: 'restoration', label: 'RESTORE' },
{ value: 'resource_cost', label: 'COST' },
{ value: 'marker', label: 'MARKER' }
];
const scopeOptions = $derived([
{ value: 'self', label: 'SELF' },
{ value: 'equipped_weapon', label: 'WEAPON' },
{ value: 'target', label: 'TARGET' },
{ value: 'allies', label: 'ALLIES' },
{ value: 'self_and_allies', label: 'SELF + ALLIES' },
...(allowAssignedAbility ? [{ value: 'assigned_ability', label: 'ABILITY' }] : []),
{ value: 'all_skills', label: 'ALL SKILLS' }
]);
const slug = (value: string) => value.toLocaleLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'branch';
const clone = <T,>(value: T): T => structuredClone(value);
const setRule = (next: ConditionalMechanicDraftV1) => { rule = next; };
function recipeParams() {
if (recipeKey === 'same_target_hits_within_window') return { hitsRequired: recipeCount, windowSeconds: recipeWindow };
if (recipeKey === 'distinct_enemies_within_window') return { enemiesRequired: recipeCount, windowSeconds: recipeWindow };
if (recipeKey === 'consecutive_same_target_hits') return { hitsRequired: recipeCount };
if (recipeKey === 'continuous_firing_duration') return { seconds: recipeWindow };
if (recipeKey === 'target_hit_within_distance' || recipeKey === 'within_distance') return { meters: recipeDistance };
if (recipeKey === 'health_threshold_bands') return { bands: [{ thresholdPercent: 50, outcomeKey: 'active' }] };
return {};
}
function applyRecipe(replace = false) {
const result = createMechanicRecipe(recipeKey, recipeParams() as never);
if (result.status !== 'supported') return;
const clauses = result.seed.clauses.map((seed, index): ConditionalMechanicClauseDraft => ({
key: `${slug(recipeKey)}-${rule.clauses.length + index + 1}`,
label: seed.label ?? TRIGGER_ACTION_REGISTRY[recipeKey].label,
event: seed.when,
recipeKey,
if: seed.if ?? { kind: 'true' },
reducer: seed.reducer,
cooldownSeconds: seed.cooldownSeconds,
weight: seed.weight,
mutations: seed.mutations,
outcomes: []
}));
setRule({
...rule,
scenarioInputs: replace
? clone(result.seed.scenarioInputs ?? [])
: [...rule.scenarioInputs, ...clone(result.seed.scenarioInputs ?? [])],
pools: replace
? (result.seed.pools ?? []).map((pool) => ({ ...clone(pool), stateKind: 'stack' as const }))
: [
...rule.pools,
...(result.seed.pools ?? [])
.filter((pool) => !rule.pools.some((current) => current.key === pool.key))
.map((pool) => ({ ...clone(pool), stateKind: 'stack' as const }))
],
clauses: replace ? clauses : [...rule.clauses, ...clauses]
});
replaceConfirmation = false;
}
function blankClause(): ConditionalMechanicClauseDraft {
const number = rule.clauses.length + 1;
return {
key: `branch-${number}`,
label: `Branch ${number}`,
event: 'weapon_hit',
if: { kind: 'true' },
reducer: { kind: 'none' },
mutations: [],
outcomes: []
};
}
function blankOutcome(index: number): ConditionalMechanicOutcomeDraft {
return {
key: `effect-${index + 1}`,
label: `Effect ${index + 1}`,
phase: 'while_active',
kind: 'attribute_modifier',
scope: 'self',
basis: 'flat',
perStack: true,
descriptionSlot: index + 1,
attributeId: '',
variants: Object.fromEntries(valueColumns.map((column) => [
column.variantKey,
{ value: 0 }
]))
};
}
function updateClause(index: number, patch: Partial<ConditionalMechanicClauseDraft>) {
const clauses = [...rule.clauses];
clauses[index] = { ...clauses[index], ...patch };
setRule({ ...rule, clauses });
}
function reducerKind(index: number, kind: string) {
const reducer: Reducer =
kind === 'count' ? { kind: 'count', threshold: 1 }
: kind === 'distinct_count' ? { kind: 'distinct_count', threshold: 1, windowSeconds: 1, distinctBy: { source: 'event', path: 'targetId' } }
: kind === 'consecutive_count' ? { kind: 'consecutive_count', threshold: 1, groupBy: { source: 'event', path: 'targetId' } }
: kind === 'sliding_count' ? { kind: 'sliding_count', threshold: 1, windowSeconds: 1 }
: kind === 'duration' ? { kind: 'duration', seconds: 1 }
: kind === 'periodic' ? { kind: 'periodic', intervalSeconds: 1 }
: kind === 'derived_scalar' ? { kind: 'derived_scalar', value: 1 }
: { kind: 'none' };
updateClause(index, { reducer });
}
function setReducerNumber(index: number, property: string, raw: string) {
const value = Number(raw);
const reducer = { ...rule.clauses[index].reducer, [property]: Number.isFinite(value) ? value : 0 } as Reducer;
updateClause(index, { reducer });
}
function updateOutcome(clauseIndex: number, outcomeIndex: number, patch: Partial<ConditionalMechanicOutcomeDraft>) {
const outcomes = [...rule.clauses[clauseIndex].outcomes];
outcomes[outcomeIndex] = { ...outcomes[outcomeIndex], ...patch };
updateClause(clauseIndex, { outcomes });
}
function updateVariant(clauseIndex: number, outcomeIndex: number, variant: string, raw: string) {
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
const value = Number(raw);
const previous = outcome.variants[variant]?.expression;
updateOutcome(clauseIndex, outcomeIndex, {
variants: {
...outcome.variants,
[variant]: {
...outcome.variants[variant],
value,
...(previous === undefined ? {} : { expression: replaceExpressionBase(previous, value) })
}
}
});
}
type OutcomeValueModel =
| 'static'
| 'event'
| 'reducer'
| 'scenario'
| 'protocol_upgrades'
| 'advanced';
const valueModelOptions = $derived([
{ value: 'static', label: 'STATIC' },
{ value: 'event', label: 'EVENT FIELD' },
{ value: 'reducer', label: 'REDUCER' },
{ value: 'scenario', label: 'SCENARIO' },
...(allowProtocolUpgradeScaling
? [{ value: 'protocol_upgrades', label: 'PROTOCOL UPGRADES' }]
: [])
]);
const eventValueOptions = [
{ value: 'values.enemyCount', label: 'ENEMY COUNT' },
{ value: 'values.allyCount', label: 'ALLY COUNT' },
{ value: 'values.magazineRemaining', label: 'ROUNDS LEFT' },
{ value: 'values.magazinePercent', label: 'MAGAZINE %' },
{ value: 'values.distanceMeters', label: 'DISTANCE' }
];
function referenceExpression(source: 'event' | 'scenario', path: string, value: number): NumericExpression {
return {
op: 'multiply',
left: value,
right: {
ref: source === 'event'
? { source, path }
: { source, key: path }
}
};
}
function protocolUpgradeExpression(value: number): NumericExpression {
return {
op: 'multiply',
left: value,
right: {
op: 'add',
left: 1,
right: {
op: 'divide',
left: { ref: { source: 'equipment', path: 'protocolTalentUpgrades' } },
right: 5
}
}
};
}
function outcomeValueModel(outcome: ConditionalMechanicOutcomeDraft): OutcomeValueModel {
const expression = Object.values(outcome.variants)[0]?.expression;
if (expression === undefined) return 'static';
if (
typeof expression === 'object'
&& 'op' in expression
&& expression.op === 'multiply'
&& typeof expression.left === 'number'
&& typeof expression.right === 'object'
) {
if ('ref' in expression.right) {
if (expression.right.ref.source === 'event') return 'event';
if (expression.right.ref.source === 'scenario') return 'scenario';
if (expression.right.ref.source === 'reducer') return 'reducer';
}
if (
'op' in expression.right
&& expression.right.op === 'add'
&& typeof expression.right.right === 'object'
&& 'op' in expression.right.right
&& expression.right.right.op === 'divide'
&& typeof expression.right.right.left === 'object'
&& 'ref' in expression.right.right.left
&& expression.right.right.left.ref.source === 'equipment'
&& expression.right.right.left.ref.path === 'protocolTalentUpgrades'
) return 'protocol_upgrades';
}
return 'advanced';
}
function outcomeReference(outcome: ConditionalMechanicOutcomeDraft, source: 'event' | 'scenario') {
const expression = Object.values(outcome.variants)[0]?.expression;
if (
expression
&& typeof expression === 'object'
&& 'op' in expression
&& expression.op === 'multiply'
&& typeof expression.right === 'object'
&& 'ref' in expression.right
&& expression.right.ref.source === source
) {
if (expression.right.ref.source === 'event') return expression.right.ref.path;
if (expression.right.ref.source === 'scenario') return expression.right.ref.key;
}
return source === 'event' ? 'values.enemyCount' : rule.scenarioInputs[0]?.key ?? '';
}
function expressionForModel(
model: OutcomeValueModel,
value: number,
reference = ''
): NumericExpression | undefined {
if (model === 'event') return referenceExpression('event', reference || 'values.enemyCount', value);
if (model === 'scenario') return reference
? referenceExpression('scenario', reference, value)
: undefined;
if (model === 'reducer') {
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
}
if (model === 'protocol_upgrades') return protocolUpgradeExpression(value);
return undefined;
}
function setOutcomeValueModel(
clauseIndex: number,
outcomeIndex: number,
model: OutcomeValueModel,
reference = ''
) {
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
const variants = Object.fromEntries(Object.entries(outcome.variants).map(([key, variant]) => {
const expression = expressionForModel(model, Number(variant.value), reference);
return [key, expression === undefined ? { value: Number(variant.value) } : {
value: Number(variant.value),
expression
}];
}));
updateOutcome(clauseIndex, outcomeIndex, { variants });
}
function replaceExpressionBase(expression: NumericExpression, value: number): NumericExpression {
const model = outcomeValueModel({
key: 'temporary',
label: 'Temporary',
phase: 'on_event',
kind: 'marker',
scope: 'self',
basis: 'flat',
variants: { base: { value, expression } }
});
if (model === 'event') {
return referenceExpression('event', (
expression as { right: { ref: { path: string } } }
).right.ref.path, value);
}
if (model === 'scenario') {
return referenceExpression('scenario', (
expression as { right: { ref: { key: string } } }
).right.ref.key, value);
}
if (model === 'reducer') {
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
}
if (model === 'protocol_upgrades') return protocolUpgradeExpression(value);
return expression;
}
function addMutation(clauseIndex: number) {
const pool = rule.pools[0]?.key;
if (!pool) return;
updateClause(clauseIndex, {
mutations: [...rule.clauses[clauseIndex].mutations, { kind: 'add', pool, value: 1 }]
});
}
function updateMutation(clauseIndex: number, mutationIndex: number, kind: 'add' | 'set' | 'consume' | 'clear', pool?: string, value?: number) {
const mutations = [...rule.clauses[clauseIndex].mutations];
const current = mutations[mutationIndex];
const nextPool = pool ?? ('pool' in current ? current.pool : rule.pools[0]?.key ?? '');
mutations[mutationIndex] = kind === 'clear'
? { kind, pool: nextPool }
: { kind, pool: nextPool, value: value ?? ('value' in current && typeof current.value === 'number' ? current.value : 1) };
updateClause(clauseIndex, { mutations });
}
function updatePool(index: number, patch: Partial<ConditionalMechanicPoolDraft>) {
const pools = [...rule.pools];
pools[index] = { ...pools[index], ...patch };
setRule({ ...rule, pools });
}
function updateScenarioInput(index: number, patch: Partial<ScenarioInputDefinition>) {
const scenarioInputs = [...rule.scenarioInputs];
scenarioInputs[index] = { ...scenarioInputs[index], ...patch };
setRule({ ...rule, scenarioInputs });
}
function addScenarioInput() {
const number = rule.scenarioInputs.length + 1;
setRule({
...rule,
scenarioInputs: [
...rule.scenarioInputs,
{
key: `assumption-${number}`,
label: `Assumption ${number}`,
kind: 'slider',
readiness: 'requires_assumption',
description: '',
defaultValue: 0,
bestCaseValue: 100,
min: 0,
max: 100
}
]
});
}
const validation = $derived(parseConditionalMechanicDraft(rule, {
variantKeys: valueColumns.map((column) => column.variantKey),
allowedAttributeIds: new Set(attributes.map((attribute) => attribute.id))
}));
const scriptLines = $derived(validation.ok
? formatMechanicProgram(validation.value.programs[valueColumns[0]?.variantKey]).split('\n')
: ['MECHANIC INCOMPLETE', '', ...validation.errors.map((error) => `ERROR ${error}`)]);
const readinessLabel = $derived(
rule.simulationReadiness === 'ready'
? 'READY'
: rule.simulationReadiness === 'requires_assumptions'
? 'REQUIRES ASSUMPTIONS'
: 'CAPTURED ONLY'
);
</script>
<fieldset class="mechanic-editor" {disabled}>
<legend>MECHANIC PROGRAM</legend>
<div class="mechanic-heading">
<div>
<span>STRUCTURED COMBAT LOGIC</span>
<strong>{rule.clauses.length} {rule.clauses.length === 1 ? 'BRANCH' : 'BRANCHES'}</strong>
<p>Events are code-defined. Every branch, condition, state change, and outcome belongs to this item.</p>
</div>
<MechanicLanguageHelp />
</div>
<SegmentedChoice
legend="SIMULATION READINESS"
name={`${idPrefix}-readiness`}
options={readinessOptions}
bind:value={rule.simulationReadiness}
/>
<section class="scenario-panel">
<header><div><span>BUILDER CONTROLS</span><strong>SCENARIO ASSUMPTIONS</strong></div><button type="button" class="secondary compact-action" onclick={addScenarioInput}>ADD CONTROL +</button></header>
<p>These controls appear in a build’s advanced mechanic settings. Maximum Potential uses the best-case value; Custom Scenario uses the Agent’s saved value.</p>
{#each rule.scenarioInputs as input, index}
<div class="scenario-row">
<label class="field">LABEL<input value={input.label} oninput={(event) => updateScenarioInput(index,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label>
<SegmentedChoice legend="CONTROL TYPE" name={`${idPrefix}-scenario-kind-${index}`} options={[{value:'toggle',label:'TOGGLE'},{value:'slider',label:'SLIDER'},{value:'number',label:'NUMBER'}]} value={input.kind} onchange={(values) => updateScenarioInput(index,{kind:values[0] as ScenarioInputDefinition['kind'],defaultValue:values[0] === 'toggle' ? false : Number(input.defaultValue ?? 0),bestCaseValue:values[0] === 'toggle' ? true : Number(input.bestCaseValue ?? 100),min:values[0] === 'toggle' ? undefined : input.min ?? 0,max:values[0] === 'toggle' ? undefined : input.max ?? 100})}/>
<label class="field description-field">AGENT GUIDANCE<input value={input.description ?? ''} placeholder="Explain what the Agent should estimate." oninput={(event) => updateScenarioInput(index,{description:event.currentTarget.value})}/></label>
{#if input.kind === 'toggle'}
<SegmentedChoice legend="DEFAULT" name={`${idPrefix}-scenario-default-${index}`} options={[{value:'false',label:'OFF'},{value:'true',label:'ON'}]} value={String(input.defaultValue ?? false)} onchange={(values) => updateScenarioInput(index,{defaultValue:values[0] === 'true'})}/>
<SegmentedChoice legend="BEST CASE" name={`${idPrefix}-scenario-best-${index}`} options={[{value:'false',label:'OFF'},{value:'true',label:'ON'}]} value={String(input.bestCaseValue ?? true)} onchange={(values) => updateScenarioInput(index,{bestCaseValue:values[0] === 'true'})}/>
{:else}
<label class="field">MIN<input type="number" step="any" value={input.min ?? 0} oninput={(event) => updateScenarioInput(index,{min:Number(event.currentTarget.value)})}/></label>
<label class="field">MAX<input type="number" step="any" value={input.max ?? 100} oninput={(event) => updateScenarioInput(index,{max:Number(event.currentTarget.value)})}/></label>
<label class="field">DEFAULT<input type="number" step="any" value={Number(input.defaultValue ?? 0)} oninput={(event) => updateScenarioInput(index,{defaultValue:Number(event.currentTarget.value)})}/></label>
<label class="field">BEST CASE<input type="number" step="any" value={Number(input.bestCaseValue ?? input.max ?? 100)} oninput={(event) => updateScenarioInput(index,{bestCaseValue:Number(event.currentTarget.value)})}/></label>
{/if}
<button type="button" class="remove" onclick={() => setRule({...rule,scenarioInputs:rule.scenarioInputs.filter((_,current) => current !== index)})}>REMOVE</button>
</div>
{:else}
<p class="empty-state">No source-specific assumptions. This mechanic uses only the shared encounter timeline.</p>
{/each}
</section>
<section class="recipe-panel">
<header><div><span>RECIPE STARTER</span><strong>SEED AN EDITABLE BRANCH</strong></div><small>Recipes copy typed data into this item; they are not shared runtime dependencies.</small></header>
<div class="recipe-grid">
<SearchableAssignment
items={recipeItems}
mode="single"
compact
subjectLabel="RECIPE"
bind:value={recipeKey}
/>
{#if ['same_target_hits_within_window', 'distinct_enemies_within_window', 'consecutive_same_target_hits'].includes(recipeKey)}
<label class="field">COUNT<input type="number" min="1" step="1" bind:value={recipeCount}/></label>
{/if}
{#if ['same_target_hits_within_window', 'distinct_enemies_within_window', 'continuous_firing_duration'].includes(recipeKey)}
<label class="field">SECONDS<input type="number" min="0.01" step="any" bind:value={recipeWindow}/></label>
{/if}
{#if ['target_hit_within_distance', 'within_distance'].includes(recipeKey)}
<label class="field">METERS<input type="number" min="0" step="any" bind:value={recipeDistance}/></label>
{/if}
<button type="button" class="secondary" onclick={() => applyRecipe(false)}>ADD RECIPE BRANCH +</button>
{#if rule.clauses.length}
<button type="button" class="danger-quiet" onclick={() => replaceConfirmation = true}>REPLACE PROGRAM</button>
{/if}
</div>
{#if replaceConfirmation}
<div class="replace-confirm" role="alert">
<p><strong>REPLACE ALL CURRENT BRANCHES?</strong><span>This keeps no existing program data.</span></p>
<div><button type="button" class="secondary" onclick={() => replaceConfirmation = false}>CANCEL</button><button type="button" class="primary" onclick={() => applyRecipe(true)}>CONFIRM REPLACE</button></div>
</div>
{/if}
</section>
<section class="state-panel">
<header><div><span>STATE</span><strong>POOLS</strong></div><button type="button" class="secondary compact-action" onclick={() => setRule({...rule,pools:[...rule.pools,{key:`state-${rule.pools.length + 1}`,label:`State ${rule.pools.length + 1}`,stateKind:'stack',scope:'agent',initial:0,min:0,max:1,bestCaseValue:1}]})}>ADD POOL +</button></header>
{#each rule.pools as pool, index}
<div class="pool-row">
<label class="field">LABEL<input value={pool.label} oninput={(event) => updatePool(index,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label>
<SegmentedChoice legend="SCOPE" name={`${idPrefix}-pool-scope-${index}`} options={[{value:'agent',label:'AGENT'},{value:'target',label:'TARGET'}]} value={pool.scope} onchange={(values) => updatePool(index,{scope:values[0] as 'agent'|'target'})}/>
<label class="field">INITIAL<input type="number" value={pool.initial} oninput={(event) => updatePool(index,{initial:Number(event.currentTarget.value)})}/></label>
<label class="field">MAX<input type="number" min="1" value={pool.max ?? 1} oninput={(event) => updatePool(index,{max:Number(event.currentTarget.value)})}/></label>
<label class="field">BEST CASE<input type="number" value={pool.bestCaseValue ?? pool.max ?? 1} oninput={(event) => updatePool(index,{bestCaseValue:Number(event.currentTarget.value)})}/></label>
<label class="field">EXPIRY · SECONDS<input type="number" min="0.01" step="any" value={pool.expiresAfterSeconds ?? ''} oninput={(event) => updatePool(index,{expiresAfterSeconds:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
<button type="button" class="remove" onclick={() => setRule({...rule,pools:rule.pools.filter((_,current) => current !== index)})}>REMOVE</button>
</div>
{:else}
<p class="empty-state">No state pools. Add one for stacks, timers, counters, resources, or target marks.</p>
{/each}
</section>
<div class="branch-stack">
{#each rule.clauses as clause, clauseIndex}
<section class="branch">
<header>
<div><span>{String(clauseIndex + 1).padStart(2, '0')}</span><label class="field">BRANCH LABEL<input value={clause.label ?? ''} oninput={(event) => updateClause(clauseIndex,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label></div>
<button type="button" class="remove" onclick={() => setRule({...rule,clauses:rule.clauses.filter((_,index) => index !== clauseIndex)})}>REMOVE BRANCH</button>
</header>
<div class="branch-grid">
<div class="field"><span>DIRECT EVENT</span><SearchableAssignment items={eventItems} mode="single" compact subjectLabel="EVENT" bind:value={clause.event}/></div>
<div class="branch-readout"><span>MATCH POLICY</span><strong>{reducerItems.find((item) => item.id === clause.reducer.kind)?.name ?? clause.reducer.kind}</strong></div>
<label class="field">COOLDOWN · SECONDS<input type="number" min="0.01" step="any" value={clause.cooldownSeconds ?? ''} oninput={(event) => updateClause(clauseIndex,{cooldownSeconds:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
</div>
<div class="reducer-controls">
<SegmentedChoice
legend="MATCH POLICY"
name={`${idPrefix}-reducer-${clauseIndex}`}
options={reducerItems.map((item) => ({value:item.id,label:item.name.replace(' matches','').replace(' within a time window',' window').toUpperCase()}))}
value={clause.reducer.kind}
columns={4}
onchange={(values) => reducerKind(clauseIndex,values[0])}
/>
{#if ['count','distinct_count','consecutive_count','sliding_count'].includes(clause.reducer.kind)}
<label class="field">THRESHOLD<input type="number" min="1" step="1" value={'threshold' in clause.reducer ? clause.reducer.threshold : 1} oninput={(event) => setReducerNumber(clauseIndex,'threshold',event.currentTarget.value)}/></label>
{/if}
{#if ['distinct_count','sliding_count'].includes(clause.reducer.kind)}
<label class="field">WINDOW · SECONDS<input type="number" min="0.01" step="any" value={'windowSeconds' in clause.reducer ? clause.reducer.windowSeconds : 1} oninput={(event) => setReducerNumber(clauseIndex,'windowSeconds',event.currentTarget.value)}/></label>
{/if}
{#if clause.reducer.kind === 'duration'}
<label class="field">DURATION · SECONDS<input type="number" min="0.01" step="any" value={clause.reducer.seconds} oninput={(event) => setReducerNumber(clauseIndex,'seconds',event.currentTarget.value)}/></label>
{:else if clause.reducer.kind === 'periodic'}
<label class="field">INTERVAL · SECONDS<input type="number" min="0.01" step="any" value={typeof clause.reducer.intervalSeconds === 'number' ? clause.reducer.intervalSeconds : 1} oninput={(event) => setReducerNumber(clauseIndex,'intervalSeconds',event.currentTarget.value)}/></label>
{:else if clause.reducer.kind === 'derived_scalar'}
<label class="field">DERIVED VALUE<input type="number" step="any" value={typeof clause.reducer.value === 'number' ? clause.reducer.value : 1} oninput={(event) => setReducerNumber(clauseIndex,'value',event.currentTarget.value)}/></label>
{/if}
</div>
<div class="mutation-panel">
<div class="outcome-heading"><div><span>STATE CHANGES</span><strong>{clause.mutations.length}</strong></div><button type="button" class="secondary compact-action" onclick={() => addMutation(clauseIndex)} disabled={!rule.pools.length}>ADD STATE CHANGE +</button></div>
{#each clause.mutations as mutation, mutationIndex}
<div class="mutation-row">
<SegmentedChoice
legend="OPERATION"
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'clear',label:'CLEAR'}]}
value={mutation.kind === 'none' || mutation.kind === 'derive' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'clear')}
/>
<SegmentedChoice
legend="STATE POOL"
name={`${idPrefix}-mutation-pool-${clauseIndex}-${mutationIndex}`}
options={rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))}
value={'pool' in mutation ? mutation.pool : rule.pools[0]?.key ?? ''}
columns={Math.min(4,Math.max(1,rule.pools.length))}
onchange={(values) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'clear' ? 'clear' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])}
/>
{#if mutation.kind !== 'clear'}
<label class="field">VALUE<input type="number" step="any" value={'value' in mutation && typeof mutation.value === 'number' ? mutation.value : 1} oninput={(event) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',undefined,Number(event.currentTarget.value))}/></label>
{/if}
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
</div>
{:else}
<p class="empty-state">This branch emits outcomes without changing shared state.</p>
{/each}
</div>
<div class="outcome-heading"><div><span>OUTCOMES</span><strong>{clause.outcomes.length}</strong></div><button type="button" class="secondary compact-action" onclick={() => updateClause(clauseIndex,{outcomes:[...clause.outcomes,blankOutcome(clause.outcomes.length)]})}>ADD OUTCOME +</button></div>
{#each clause.outcomes as outcome, outcomeIndex}
<div class="outcome-card">
<div class="outcome-grid">
<label class="field">LABEL<input value={outcome.label} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label>
<SegmentedChoice legend="KIND" name={`${idPrefix}-kind-${clauseIndex}-${outcomeIndex}`} options={outcomeKinds} value={outcome.kind} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{kind:values[0] as ConditionalMechanicOutcomeDraft['kind'],resource:values[0] === 'restoration' || values[0] === 'resource_cost' ? outcome.resource ?? 'health' : undefined,attributeId:values[0] === 'attribute_modifier' ? outcome.attributeId ?? '' : undefined})}/>
<label class="field">DESCRIPTION SLOT<input type="number" min="1" step="1" value={outcome.descriptionSlot ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{descriptionSlot:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
</div>
<SegmentedChoice legend="TARGET SCOPE" name={`${idPrefix}-scope-${clauseIndex}-${outcomeIndex}`} options={scopeOptions} value={outcome.scope} columns={scopeOptions.length > 4 ? 4 : scopeOptions.length} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{scope:values[0] as MechanicOutcomeScope})}/>
{#if outcome.kind === 'attribute_modifier'}
<SearchableAssignment items={attributes} mode="single" compact subjectLabel="ATTRIBUTE" bind:value={outcome.attributeId}/>
{:else if outcome.kind === 'restoration' || outcome.kind === 'resource_cost'}
<SegmentedChoice legend="RESOURCE" name={`${idPrefix}-resource-${clauseIndex}-${outcomeIndex}`} options={[{value:'health',label:'HEALTH'},{value:'armor',label:'ARMOR'},{value:'ammo',label:'AMMO'}]} value={outcome.resource ?? 'health'} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{resource:values[0] as 'health'|'armor'|'ammo'})}/>
{/if}
{#if outcome.kind === 'direct_damage' || outcome.kind === 'restoration' || outcome.kind === 'resource_cost'}
<SegmentedChoice legend="VALUE BASIS" name={`${idPrefix}-basis-${clauseIndex}-${outcomeIndex}`} options={[{value:'flat',label:'FLAT'},{value:'max_health_percent',label:'MAX HEALTH %'},{value:'current_health_percent',label:'CURRENT HEALTH %'},{value:'weapon_base_percent',label:'WEAPON BASE %'}]} value={outcome.basis} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{basis:values[0] as OutcomeBasis})}/>
{/if}
<SegmentedChoice
legend="VALUE MODEL"
name={`${idPrefix}-value-model-${clauseIndex}-${outcomeIndex}`}
options={outcomeValueModel(outcome) === 'advanced' ? [...valueModelOptions,{value:'advanced',label:'ADVANCED',disabled:true}] : valueModelOptions}
value={outcomeValueModel(outcome)}
columns={3}
onchange={(values) => setOutcomeValueModel(clauseIndex,outcomeIndex,values[0] as OutcomeValueModel,values[0] === 'event' ? 'values.enemyCount' : values[0] === 'scenario' ? rule.scenarioInputs[0]?.key ?? '' : '')}
/>
{#if outcomeValueModel(outcome) === 'event'}
<SegmentedChoice
legend="EVENT FIELD"
name={`${idPrefix}-event-value-${clauseIndex}-${outcomeIndex}`}
options={eventValueOptions}
value={outcomeReference(outcome,'event')}
columns={3}
onchange={(values) => setOutcomeValueModel(clauseIndex,outcomeIndex,'event',values[0])}
/>
{:else if outcomeValueModel(outcome) === 'scenario' && rule.scenarioInputs.length}
<SegmentedChoice
legend="SCENARIO CONTROL"
name={`${idPrefix}-scenario-value-${clauseIndex}-${outcomeIndex}`}
options={rule.scenarioInputs.map((input) => ({value:input.key,label:input.label.toUpperCase()}))}
value={outcomeReference(outcome,'scenario')}
columns={Math.min(4,rule.scenarioInputs.length)}
onchange={(values) => setOutcomeValueModel(clauseIndex,outcomeIndex,'scenario',values[0])}
/>
{/if}
{#if rule.pools.length}
<div class="state-scaling">
<SegmentedChoice
legend="STATE POOL"
name={`${idPrefix}-outcome-pool-${clauseIndex}-${outcomeIndex}`}
options={[{value:'',label:'NONE'},...rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))]}
value={outcome.statePool ?? ''}
columns={Math.min(4,rule.pools.length + 1)}
onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{statePool:values[0] || undefined,perStack:values[0] ? outcome.perStack ?? true : false,minStacks:values[0] ? outcome.minStacks : undefined,maxStacks:values[0] ? outcome.maxStacks : undefined})}
/>
{#if outcome.statePool}
<SegmentedChoice legend="SCALING" name={`${idPrefix}-outcome-scaling-${clauseIndex}-${outcomeIndex}`} options={[{value:'false',label:'FLAT'},{value:'true',label:'PER STACK'}]} value={String(outcome.perStack ?? true)} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{perStack:values[0] === 'true'})}/>
<label class="field">MIN STACK<input type="number" min="0" step="1" value={outcome.minStacks ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{minStacks:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
<label class="field">MAX STACK<input type="number" min="0" step="1" value={outcome.maxStacks ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{maxStacks:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
{/if}
</div>
{/if}
<div class="variant-grid">
{#each valueColumns as column}
<label class="field">{column.label} VALUE<input type="number" step="any" value={outcome.variants[column.variantKey]?.value ?? 0} oninput={(event) => updateVariant(clauseIndex,outcomeIndex,column.variantKey,event.currentTarget.value)}/></label>
{/each}
</div>
<div class="outcome-footer">
<SegmentedChoice legend="PHASE" name={`${idPrefix}-phase-${clauseIndex}-${outcomeIndex}`} options={[{value:'on_event',label:'ON EVENT'},{value:'after_event',label:'AFTER EVENT'},{value:'while_active',label:'WHILE ACTIVE'},{value:'on_enter',label:'ON ENTER'},{value:'on_exit',label:'ON EXIT'}]} value={outcome.phase} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{phase:values[0] as OutcomePhase})}/>
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{outcomes:clause.outcomes.filter((_,index) => index !== outcomeIndex)})}>REMOVE OUTCOME</button>
</div>
</div>
{:else}
<p class="empty-state">This branch has no outcomes yet.</p>
{/each}
</section>
{/each}
<button type="button" class="secondary add-branch" onclick={() => setRule({...rule,clauses:[...rule.clauses,blankClause()]})}>ADD DIRECT EVENT BRANCH +</button>
</div>
<section class="script-preview">
<div><span>GENERATED MECHANIC</span><strong>READ-ONLY PSEUDO-SCRIPT</strong><p>The server validates the same typed structure for every configured value variant.</p></div>
<PseudoScript title="CURRENT MECHANIC PROGRAM" lines={scriptLines} readiness={readinessLabel}/>
</section>
</fieldset>
<style>
.mechanic-editor{display:grid;gap:var(--shd-gap-sm);margin:0;padding:var(--shd-gap-sm);border:1px solid var(--shd-model-border);border-left:2px solid var(--shd-orange);background:var(--shd-model-surface)}
.mechanic-editor>legend{padding:0 7px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.2px}
.mechanic-heading,.recipe-panel>header,.scenario-panel>header,.state-panel>header,.branch>header,.outcome-heading{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm)}
.mechanic-heading{padding:8px 10px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}
.mechanic-heading>div,.recipe-panel>header>div,.scenario-panel>header>div,.state-panel>header>div,.outcome-heading>div{display:grid;gap:2px}
.mechanic-heading span,.recipe-panel header span,.scenario-panel header span,.state-panel header span,.outcome-heading span{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.9px}
.mechanic-heading p,.recipe-panel header small,.empty-state,.script-preview>div p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
.recipe-panel,.scenario-panel,.state-panel,.branch{border:1px solid var(--shd-border);background:var(--shd-surface)}
.recipe-panel>header,.scenario-panel>header,.state-panel>header,.branch>header,.outcome-heading{min-height:42px;padding:7px 9px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}
.scenario-panel>p{margin:0;padding:8px 9px;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4;border-bottom:1px solid var(--shd-border-soft)}
.scenario-row{display:grid;grid-template-columns:minmax(170px,1fr) minmax(260px,1.25fr) minmax(220px,1.3fr) repeat(4,minmax(85px,.45fr)) auto;align-items:end;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border-soft)}
.scenario-row>.field{margin:0}.scenario-row>.description-field{min-width:0}
.recipe-grid{display:grid;grid-template-columns:minmax(260px,1fr) repeat(3,minmax(110px,.35fr)) auto auto;align-items:end;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)}
.recipe-grid>.field,.pool-row>.field,.branch-grid>.field,.outcome-grid>.field,.variant-grid>.field{margin:0}
.replace-confirm{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm);padding:9px;border-top:1px solid var(--shd-danger);background:color-mix(in srgb,var(--shd-danger) 10%,var(--shd-surface))}
.replace-confirm p{display:grid;gap:2px;margin:0}.replace-confirm span{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.replace-confirm>div{display:flex;gap:7px}
.pool-row{display:grid;grid-template-columns:1.2fr .8fr repeat(4,.55fr) auto;align-items:end;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border-soft)}
.branch-stack{display:grid;gap:var(--shd-gap-sm)}
.branch>header>div{display:flex;align-items:end;gap:9px}.branch>header>div>span{align-self:center;color:var(--shd-orange);font:900 var(--shd-type-telemetry)/1 var(--shd-font-mono)}.branch>header .field{min-width:min(360px,55vw);margin:0}
.branch-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr)) minmax(160px,.45fr);align-items:end;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)}
.field>span{display:block;margin-bottom:5px;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
.branch-readout{display:grid;align-content:center;gap:3px;min-height:44px;padding:7px 9px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}.branch-readout span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.branch-readout strong{font-size:var(--shd-type-supporting)}
.reducer-controls{display:grid;grid-template-columns:minmax(0,1fr) repeat(2,minmax(130px,.25fr));align-items:end;gap:var(--shd-gap-sm);padding:0 var(--shd-gap-sm) var(--shd-gap-sm)}
.mutation-panel{margin:0 var(--shd-gap-sm) var(--shd-gap-sm);border:1px solid var(--shd-border-soft)}
.mutation-row{display:grid;grid-template-columns:minmax(300px,1fr) minmax(260px,1fr) minmax(110px,.35fr) auto;align-items:end;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border-soft)}
.outcome-card{display:grid;gap:8px;margin:var(--shd-gap-sm);padding:9px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}
.outcome-grid{display:grid;grid-template-columns:minmax(200px,1fr) minmax(360px,1.4fr) minmax(120px,.3fr);align-items:end;gap:8px}
.variant-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px}
.state-scaling{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr) repeat(2,minmax(110px,.3fr));align-items:end;gap:8px}
.outcome-footer{display:flex;align-items:end;justify-content:space-between;gap:8px}
.remove,.danger-quiet{min-height:38px;padding:0 10px;border:1px solid var(--shd-danger);background:transparent;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}
.remove:hover,.remove:focus-visible,.danger-quiet:hover,.danger-quiet:focus-visible{background:var(--shd-danger);color:var(--shd-surface)}
.compact-action{min-height:36px}.add-branch{min-height:44px}.empty-state{padding:10px}.script-preview{display:grid;grid-template-columns:minmax(190px,.28fr) minmax(0,1fr);gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-border);border-top:2px solid var(--shd-success);background:var(--shd-surface)}
.script-preview>div{display:grid;align-content:start;gap:3px}.script-preview>div span{color:var(--shd-success);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
@media(max-width:1300px){.scenario-row{grid-template-columns:repeat(2,minmax(0,1fr))}.scenario-row>.description-field{grid-column:1/-1}}
@media(max-width:1000px){.recipe-grid,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.script-preview{grid-template-columns:1fr 1fr}.recipe-grid>:first-child,.script-preview>:last-child{grid-column:1/-1}.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:1/-1}}
@media(max-width:620px){.mechanic-heading,.recipe-panel>header,.scenario-panel>header,.state-panel>header,.branch>header,.outcome-heading,.replace-confirm,.outcome-footer{align-items:stretch;flex-direction:column}.recipe-grid,.scenario-row,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.script-preview{grid-template-columns:1fr}.recipe-grid>:first-child,.script-preview>:last-child,.scenario-row>.description-field,.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:auto}.branch>header>div{align-items:stretch;flex-direction:column}.branch>header .field{min-width:0}.remove,.danger-quiet,.compact-action,.add-branch{width:100%;min-height:44px}}
@media(prefers-reduced-motion:reduce){.remove,.danger-quiet{transition:none}}
</style>