Files
shd.build/src/lib/components/isac/MechanicProgramEditor.svelte
T
jb d09b6220ee
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 27s
Validate and deploy staging / deploy (push) Successful in 54s
feat: defer advanced build calculations
2026-08-02 03:07:12 -04:00

1226 lines
65 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 MechanicAuthoringAssistant from '$lib/components/isac/MechanicAuthoringAssistant.svelte';
import type { AiMechanicContext } from '$lib/ai/mechanic-authoring';
import {
blankConditionalMechanicDraft,
currentHealthPercentBandPredicate,
eventFieldComparisonPredicate,
parseConditionalMechanicDraft,
readCurrentHealthPercentBand,
readEventFieldComparison,
registeredEventFields,
readStatePoolThreshold,
statePoolThresholdPredicate,
type ConditionalMechanicClauseDraft,
type ConditionalMechanicDraftV1,
type ConditionalMechanicOutcomeDraft,
type ConditionalMechanicPoolDraft,
type EditablePredicateScalar,
type EventFieldComparison,
type PredicateComparisonOperator,
type StatePoolThresholdComparison
} 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,
aiContext,
disabled = false
}: {
rule?: ConditionalMechanicDraftV1;
attributes: Attribute[];
valueColumns: MechanicValueColumn[];
idPrefix?: string;
allowAssignedAbility?: boolean;
allowProtocolUpgradeScaling?: boolean;
aiContext?: AiMechanicContext;
disabled?: boolean;
} = $props();
let recipeKey = $state<TriggerActionKey | ''>('');
let recipeCount = $state(9);
let recipeWindow = $state(3);
let recipeDistance = $state(10);
let recipeHealthBands = $state([
{ thresholdPercent: 25, outcomeKey: '15-percent-reduction' },
{ thresholdPercent: 50, outcomeKey: '12-percent-reduction' },
{ thresholdPercent: 75, outcomeKey: '9-percent-reduction' }
]);
let replaceConfirmation = $state(false);
let valueModelOverrides = $state<Record<string, OutcomeValueModel>>({});
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: 'weapon_reload', label: 'WEAPON RELOAD' },
{ value: 'marker', label: 'MARKER' }
];
const scopeOptions = $derived([
{ value: 'self', label: 'SELF' },
{ value: 'equipped_weapon', label: 'WEAPON' },
{ value: 'inactive_weapon', label: 'INACTIVE 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 predicateOperatorOptions = [
{ value: 'eq', label: '=' },
{ value: 'ne', label: '≠' },
{ value: 'gt', label: '>' },
{ value: 'gte', label: '≥' },
{ value: 'lt', label: '<' },
{ value: 'lte', label: '≤' }
];
const predicateScalarTypeOptions = [
{ value: 'string', label: 'TEXT' },
{ value: 'number', label: 'NUMBER' },
{ value: 'boolean', label: 'BOOLEAN' }
];
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: recipeHealthBands };
return {};
}
function conditionModel(clause: ConditionalMechanicClauseDraft) {
if (!clause.if || clause.if.kind === 'true') return 'always';
if (readCurrentHealthPercentBand(clause.if)) return 'current_health_band';
if (readEventFieldComparison(clause.if)) return 'event_field';
if (readStatePoolThreshold(clause.if)) return 'state_pool_threshold';
return 'advanced';
}
function conditionOptions(clause: ConditionalMechanicClauseDraft) {
const options = [
{ value: 'always', label: 'ALWAYS' },
{ value: 'current_health_band', label: 'HEALTH BAND' },
{ value: 'event_field', label: 'EVENT FIELD' },
{ value: 'state_pool_threshold', label: 'STATE THRESHOLD', disabled: !rule.pools.length }
];
return conditionModel(clause) === 'advanced'
? [
...options.map((option) => ({ ...option, disabled: true })),
{ value: 'advanced', label: 'ADVANCED', disabled: true }
]
: options;
}
function eventPredicateFields(clause: ConditionalMechanicClauseDraft) {
return registeredEventFields(clause.event);
}
function selectedEventPredicateField(clause: ConditionalMechanicClauseDraft) {
const comparison = readEventFieldComparison(clause.if);
return eventPredicateFields(clause).find((field) => field.path === comparison?.path);
}
function eventPredicateOperatorOptions(clause: ConditionalMechanicClauseDraft) {
return selectedEventPredicateField(clause)?.type === 'number'
? predicateOperatorOptions
: predicateOperatorOptions.filter((option) => option.value === 'eq' || option.value === 'ne');
}
function setClauseEvent(clauseIndex: number, event: string) {
const nextEvent = event as ConditionalMechanicClauseDraft['event'];
const clause = rule.clauses[clauseIndex];
const fields = registeredEventFields(nextEvent);
const comparison = readEventFieldComparison(clause.if);
if (!comparison || !fields.length) {
updateClause(clauseIndex, { event: nextEvent });
return;
}
const field = fields[0];
updateClause(clauseIndex, {
event: nextEvent,
if: eventFieldComparisonPredicate({
path: field.path,
operator: 'eq',
value: field.type === 'boolean' ? true : field.type === 'number' ? 0 : ''
})
});
}
function setConditionModel(clauseIndex: number, model: string) {
if (model === 'always') updateClause(clauseIndex, { if: { kind: 'true' } });
if (model === 'current_health_band') {
updateClause(clauseIndex, {
if: currentHealthPercentBandPredicate({ maximum: 75 })
});
}
if (model === 'event_field') {
const field = eventPredicateFields(rule.clauses[clauseIndex])[0];
updateClause(clauseIndex, {
if: eventFieldComparisonPredicate({
path: field?.path ?? 'values.state',
operator: 'eq',
value: field?.type === 'boolean' ? true : field?.type === 'number' ? 0 : 'moving'
})
});
}
if (model === 'state_pool_threshold' && rule.pools[0]) {
updateClause(clauseIndex, {
if: statePoolThresholdPredicate({
pool: rule.pools[0].key,
operator: 'gt',
value: 0
})
});
}
}
function updateHealthBand(
clauseIndex: number,
boundary: 'minimum' | 'maximum',
rawValue: string
) {
const current = readCurrentHealthPercentBand(rule.clauses[clauseIndex].if) ?? { maximum: 75 };
const numeric = rawValue === '' ? undefined : Number(rawValue);
const next = {
...(boundary === 'minimum'
? numeric === undefined ? {} : { minimum: numeric }
: current.minimum === undefined ? {} : { minimum: current.minimum }),
maximum: boundary === 'maximum' ? numeric ?? current.maximum : current.maximum
};
updateClause(clauseIndex, { if: currentHealthPercentBandPredicate(next) });
}
function updateEventFieldComparison(
clauseIndex: number,
patch: Partial<EventFieldComparison>
) {
const current = readEventFieldComparison(rule.clauses[clauseIndex].if) ?? {
path: 'values.state',
operator: 'eq',
value: 'moving'
} as const;
updateClause(clauseIndex, {
if: eventFieldComparisonPredicate({ ...current, ...patch })
});
}
function predicateScalarType(value: EditablePredicateScalar) {
return typeof value;
}
function setEventFieldScalarType(clauseIndex: number, type: string) {
const current = readEventFieldComparison(rule.clauses[clauseIndex].if);
if (!current) return;
const value: EditablePredicateScalar = type === 'number'
? (typeof current.value === 'number' ? current.value : 0)
: type === 'boolean'
? (typeof current.value === 'boolean' ? current.value : true)
: typeof current.value === 'string' ? current.value : String(current.value);
updateEventFieldComparison(clauseIndex, { value });
}
function setRegisteredEventField(clauseIndex: number, path: string) {
const clause = rule.clauses[clauseIndex];
const field = eventPredicateFields(clause).find((item) => item.path === path);
if (!field) return;
const current = readEventFieldComparison(clause.if);
updateEventFieldComparison(clauseIndex, {
path,
operator: field.type === 'number' ? current?.operator ?? 'eq' : 'eq',
value: field.type === 'boolean' ? true : field.type === 'number' ? 0 : ''
});
}
function updateStatePoolThreshold(
clauseIndex: number,
patch: Partial<StatePoolThresholdComparison>
) {
const current = readStatePoolThreshold(rule.clauses[clauseIndex].if) ?? {
pool: rule.pools[0]?.key ?? '',
operator: 'gt',
value: 0
} as const;
updateClause(clauseIndex, {
if: statePoolThresholdPredicate({ ...current, ...patch })
});
}
function applyRecipe(replace = false) {
if (!recipeKey) return;
const selectedRecipeKey = recipeKey;
const result = createMechanicRecipe(selectedRecipeKey, recipeParams() as never);
if (result.status !== 'supported') return;
const clauses = result.seed.clauses.map((seed, index): ConditionalMechanicClauseDraft => ({
key: `${slug(selectedRecipeKey)}-${rule.clauses.length + index + 1}`,
label: seed.label ?? TRIGGER_ACTION_REGISTRY[selectedRecipeKey].label,
event: seed.when,
recipeKey: selectedRecipeKey,
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: false,
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'
| 'attribute'
| 'attribute_value'
| 'reducer'
| 'scenario'
| 'protocol_upgrades'
| 'advanced';
const valueModelOptions = $derived([
{ value: 'static', label: 'STATIC' },
{ value: 'event', label: 'EVENT FIELD' },
{ value: 'attribute_value', label: 'LIVE ATTRIBUTE' },
{ value: 'attribute', label: 'ATTRIBUTE ÷ 100' },
{ 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 attributePercentExpression(value: number, attributeId: string): NumericExpression {
return {
op: 'multiply',
left: value,
right: {
op: 'divide',
left: { ref: { source: 'attribute', attributeId } },
right: 100
}
};
}
function liveAttributeExpression(value: number, attributeId: string): NumericExpression {
return {
op: 'multiply',
left: value,
right: { ref: { source: 'attribute', attributeId } }
};
}
function outcomeValueModel(outcome: ConditionalMechanicOutcomeDraft): OutcomeValueModel {
if (valueModelOverrides[outcome.key]) return valueModelOverrides[outcome.key];
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 (expression.right.ref.source === 'attribute') return 'attribute_value';
}
if (
'op' in expression.right
&& expression.right.op === 'divide'
&& typeof expression.right.left === 'object'
&& 'ref' in expression.right.left
&& expression.right.left.ref.source === 'attribute'
&& expression.right.right === 100
) return 'attribute';
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 outcomeAttributeReference(outcome: ConditionalMechanicOutcomeDraft) {
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 === 'attribute'
) return expression.right.ref.attributeId;
if (
expression
&& typeof expression === 'object'
&& 'op' in expression
&& expression.op === 'multiply'
&& typeof expression.right === 'object'
&& 'op' in expression.right
&& expression.right.op === 'divide'
&& typeof expression.right.left === 'object'
&& 'ref' in expression.right.left
&& expression.right.left.ref.source === 'attribute'
) return expression.right.left.ref.attributeId;
return '';
}
function expressionForModel(
model: OutcomeValueModel,
value: number,
reference = ''
): NumericExpression | undefined {
if (model === 'event') return referenceExpression('event', reference || 'values.enemyCount', value);
if (model === 'attribute') return reference
? attributePercentExpression(value, reference)
: undefined;
if (model === 'attribute_value') return reference
? liveAttributeExpression(value, reference)
: undefined;
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 nextOverrides = { ...valueModelOverrides };
if ((model === 'attribute' || model === 'attribute_value') && !reference) {
nextOverrides[outcome.key] = model;
}
else delete nextOverrides[outcome.key];
valueModelOverrides = nextOverrides;
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 === 'attribute') {
return attributePercentExpression(value, outcomeAttributeReference({
key: 'temporary',
label: 'Temporary',
phase: 'on_event',
kind: 'marker',
scope: 'self',
basis: 'flat',
variants: { base: { value, expression } }
}));
}
if (model === 'attribute_value') {
return liveAttributeExpression(value, outcomeAttributeReference({
key: 'temporary',
label: 'Temporary',
phase: 'on_event',
kind: 'marker',
scope: 'self',
basis: 'flat',
variants: { base: { value, expression } }
}));
}
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;
const entity = rule.pools[0]?.scope === 'target'
? { kind: 'event_target' as const }
: undefined;
updateClause(clauseIndex, {
mutations: [...rule.clauses[clauseIndex].mutations, { kind: 'add', pool, value: 1, entity }]
});
}
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 ?? '');
const nextPoolDefinition = rule.pools.find((candidate) => candidate.key === nextPool);
const entity = nextPoolDefinition?.scope === 'target'
? ('entity' in current && current.entity ? current.entity : { kind: 'event_target' as const })
: undefined;
mutations[mutationIndex] = kind === 'clear'
? { kind, pool: nextPool, entity }
: { kind, pool: nextPool, value: value ?? ('value' in current && typeof current.value === 'number' ? current.value : 1), entity };
updateClause(clauseIndex, { mutations });
}
function updateMutationEntity(
clauseIndex: number,
mutationIndex: number,
kind: 'event_target' | 'event_actor'
) {
const mutations = [...rule.clauses[clauseIndex].mutations];
const mutation = mutations[mutationIndex];
if (!('pool' in mutation) || mutation.kind === 'transfer') return;
mutations[mutationIndex] = { ...mutation, entity: { kind } };
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>
{#if aiContext}
<MechanicAuthoringAssistant
context={aiContext}
currentDraft={rule}
{valueColumns}
{idPrefix}
{allowAssignedAbility}
{allowProtocolUpgradeScaling}
{disabled}
onapply={setRule}
/>
{/if}
<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 are retained for the planned advanced build model. Maximum Potential will use the best-case value; Custom Scenario will use 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"
emptySelectionLabel="NO RECIPE SELECTED"
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}
{#if recipeKey === 'health_threshold_bands'}
<div class="health-band-config">
<p>Define strict “below Health” bands from strongest to weakest. Exact thresholds enter the next weaker band.</p>
{#each recipeHealthBands as band, index}
<div>
<label class="field">BELOW · HEALTH %<input type="number" min="0.01" max="100" step="any" bind:value={band.thresholdPercent}/></label>
<label class="field">BAND LABEL<input maxlength="80" bind:value={band.outcomeKey}/></label>
<button type="button" class="remove" onclick={() => recipeHealthBands = recipeHealthBands.filter((_, current) => current !== index)} disabled={recipeHealthBands.length === 1}>REMOVE</button>
</div>
{/each}
<button type="button" class="secondary compact-action" onclick={() => recipeHealthBands = [...recipeHealthBands,{thresholdPercent:Math.min(100,(recipeHealthBands.at(-1)?.thresholdPercent ?? 0) + 25),outcomeKey:`health-band-${recipeHealthBands.length + 1}`}]} disabled={recipeHealthBands.length >= 5}>ADD BAND +</button>
</div>
{/if}
<button type="button" class="secondary" disabled={!recipeKey} onclick={() => applyRecipe(false)}>ADD RECIPE BRANCH +</button>
{#if rule.clauses.length && recipeKey}
<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>
{#if pool.expiresAfterSecondsVariants}
{#each valueColumns as column}
<label class="field">EXPIRY · {column.label}<input type="number" min="0.01" step="any" value={pool.expiresAfterSecondsVariants?.[column.variantKey] ?? ''} oninput={(event) => updatePool(index,{expiresAfterSecondsVariants:{...pool.expiresAfterSecondsVariants,[column.variantKey]:Number(event.currentTarget.value)}})}/></label>
{/each}
{:else}
<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>
{/if}
<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}
{@const conditionBand = readCurrentHealthPercentBand(clause.if)}
{@const eventFieldCondition = readEventFieldComparison(clause.if)}
{@const registeredPredicateField = selectedEventPredicateField(clause)}
{@const registeredPredicateFields = eventPredicateFields(clause)}
{@const statePoolCondition = readStatePoolThreshold(clause.if)}
<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" value={clause.event} onvaluechange={(value) => setClauseEvent(clauseIndex,value)}/></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="condition-controls">
<SegmentedChoice
legend="BRANCH CONDITION"
name={`${idPrefix}-condition-${clauseIndex}`}
options={conditionOptions(clause)}
value={conditionModel(clause)}
onchange={(values) => setConditionModel(clauseIndex,values[0])}
/>
{#if conditionBand}
<div class="health-boundaries">
<label class="field">FROM · HEALTH %<input type="number" min="0" max="100" step="any" value={conditionBand.minimum ?? ''} placeholder="0" oninput={(event) => updateHealthBand(clauseIndex,'minimum',event.currentTarget.value)}/><small>Inclusive; leave blank for zero.</small></label>
<label class="field">BELOW · HEALTH %<input type="number" min="0.01" max="100" step="any" value={conditionBand.maximum} oninput={(event) => updateHealthBand(clauseIndex,'maximum',event.currentTarget.value)}/><small>Exclusive; exact values do not match.</small></label>
</div>
{:else if eventFieldCondition}
<div class="predicate-fields event-predicate-fields">
{#if registeredPredicateFields.length && registeredPredicateField}
<SegmentedChoice
legend="EVENT FIELD"
name={`${idPrefix}-event-field-${clauseIndex}`}
options={registeredPredicateFields.map((field) => ({value:field.path,label:field.label.toUpperCase()}))}
value={registeredPredicateField.path}
onchange={(values) => setRegisteredEventField(clauseIndex,values[0])}
/>
{:else}
<label class="field">EVENT FIELD PATH<input value={eventFieldCondition.path} placeholder="values.state" oninput={(event) => updateEventFieldComparison(clauseIndex,{path:event.currentTarget.value})}/></label>
{/if}
<SegmentedChoice
legend="COMPARISON"
name={`${idPrefix}-event-operator-${clauseIndex}`}
options={eventPredicateOperatorOptions(clause)}
value={eventFieldCondition.operator}
columns={eventPredicateOperatorOptions(clause).length}
onchange={(values) => updateEventFieldComparison(clauseIndex,{operator:values[0] as PredicateComparisonOperator})}
/>
{#if !registeredPredicateField}
<SegmentedChoice
legend="VALUE TYPE"
name={`${idPrefix}-event-value-type-${clauseIndex}`}
options={predicateScalarTypeOptions}
value={predicateScalarType(eventFieldCondition.value)}
onchange={(values) => setEventFieldScalarType(clauseIndex,values[0])}
/>
{/if}
{#if typeof eventFieldCondition.value === 'number'}
<label class="field">COMPARE VALUE<input type="number" step="any" value={eventFieldCondition.value} oninput={(event) => updateEventFieldComparison(clauseIndex,{value:Number(event.currentTarget.value)})}/></label>
{:else if typeof eventFieldCondition.value === 'boolean'}
<SegmentedChoice
legend="COMPARE VALUE"
name={`${idPrefix}-event-boolean-${clauseIndex}`}
options={[{value:'true',label:'TRUE'},{value:'false',label:'FALSE'}]}
value={String(eventFieldCondition.value)}
onchange={(values) => updateEventFieldComparison(clauseIndex,{value:values[0] === 'true'})}
/>
{:else}
<label class="field">COMPARE VALUE<input value={eventFieldCondition.value} placeholder="moving" oninput={(event) => updateEventFieldComparison(clauseIndex,{value:event.currentTarget.value})}/></label>
{/if}
</div>
{:else if statePoolCondition}
<div class="predicate-fields state-predicate-fields">
<div class="field"><span>STATE POOL</span><SearchableAssignment
items={rule.pools.map((pool) => ({id:pool.key,name:pool.label}))}
mode="single"
compact
subjectLabel="STATE POOL"
value={statePoolCondition.pool}
onvaluechange={(value) => updateStatePoolThreshold(clauseIndex,{pool:value})}
/></div>
<SegmentedChoice
legend="COMPARISON"
name={`${idPrefix}-state-operator-${clauseIndex}`}
options={predicateOperatorOptions}
value={statePoolCondition.operator}
columns={6}
onchange={(values) => updateStatePoolThreshold(clauseIndex,{operator:values[0] as PredicateComparisonOperator})}
/>
<label class="field">THRESHOLD<input type="number" step="any" value={statePoolCondition.value} oninput={(event) => updateStatePoolThreshold(clauseIndex,{value:Number(event.currentTarget.value)})}/></label>
</div>
{:else if conditionModel(clause) === 'advanced'}
<p class="advanced-condition-note">This saved condition remains active and is preserved unchanged. Use the generated mechanic preview to inspect it.</p>
{/if}
</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}
{#if 'pool' in mutation && rule.pools.find((pool) => pool.key === mutation.pool)?.scope === 'target'}
<SegmentedChoice
legend="STATE OWNER"
name={`${idPrefix}-mutation-entity-${clauseIndex}-${mutationIndex}`}
options={[{value:'event_target',label:'EVENT TARGET'},{value:'event_actor',label:'EVENT ACTOR'}]}
value={'entity' in mutation && mutation.entity?.kind === 'event_actor' ? 'event_actor' : 'event_target'}
onchange={(values) => updateMutationEntity(clauseIndex,mutationIndex,values[0] as 'event_target'|'event_actor')}
/>
{/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,delivery:values[0] === 'restoration' ? outcome.delivery : undefined,durationSeconds:values[0] === 'restoration' ? outcome.durationSeconds : 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.scope === 'allies' || outcome.scope === 'self_and_allies' || outcome.scope === 'target'}
<div class="delivery-controls">
<SegmentedChoice
legend="AREA TARGETING"
name={`${idPrefix}-targeting-${clauseIndex}-${outcomeIndex}`}
options={[{value:'none',label:'NONE'},{value:'allies',label:'ALLIES IN RADIUS'},{value:'enemies',label:'ENEMIES IN RADIUS'}]}
value={outcome.targeting?.population ?? 'none'}
onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{
targeting: values[0] === 'none'
? undefined
: {kind:'within_radius',population:values[0] as 'allies'|'enemies',radiusMeters:outcome.targeting?.radiusMeters ?? 20}
})}
/>
{#if outcome.targeting}
<label class="field">RADIUS (METERS)<input type="number" min="0.01" step="any" value={outcome.targeting.radiusMeters} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{targeting:{...outcome.targeting!,radiusMeters:Number(event.currentTarget.value)}})}/></label>
{/if}
</div>
{/if}
{#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:'signature_ability_charge',label:'SIGNATURE CHARGE'}]} value={outcome.resource ?? 'health'} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{resource:values[0] as 'health'|'armor'|'ammo'|'signature_ability_charge'})}/>
{/if}
{#if outcome.kind === 'restoration'}
<div class="delivery-controls">
<SegmentedChoice
legend="DELIVERY"
name={`${idPrefix}-delivery-${clauseIndex}-${outcomeIndex}`}
options={[{value:'instant',label:'INSTANT'},{value:'continuous',label:'CONTINUOUS'}]}
value={outcome.delivery ?? 'instant'}
onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{
delivery: values[0] === 'continuous' ? 'continuous' : undefined,
durationSeconds: values[0] === 'continuous' ? outcome.durationSeconds ?? 3 : undefined
})}
/>
{#if outcome.delivery === 'continuous'}
<label class="field">DURATION (SECONDS)<input type="number" min="0.01" step="any" value={outcome.durationSeconds ?? 3} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{durationSeconds:Number(event.currentTarget.value)})}/></label>
{/if}
</div>
{/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])}
/>
{:else if outcomeValueModel(outcome) === 'attribute' || outcomeValueModel(outcome) === 'attribute_value'}
<SearchableAssignment
items={attributes}
mode="single"
compact
subjectLabel="VALUE ATTRIBUTE"
value={outcomeAttributeReference(outcome)}
onvaluechange={(value) => setOutcomeValueModel(clauseIndex,outcomeIndex,outcomeValueModel(outcome),value)}
/>
{/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)}
.health-band-config{grid-column:1/-1;display:grid;gap:var(--shd-gap-xs);padding:var(--shd-gap-sm);border:1px solid var(--shd-model-border);background:var(--shd-model-surface)}
.health-band-config>p,.advanced-condition-note{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
.health-band-config>div{display:grid;grid-template-columns:minmax(150px,.5fr) minmax(220px,1fr) auto;align-items:end;gap: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)}
.condition-controls{display:grid;grid-template-columns:minmax(260px,.7fr) minmax(320px,1.3fr);align-items:end;gap:var(--shd-gap-sm);padding:0 var(--shd-gap-sm) var(--shd-gap-sm)}
.health-boundaries{display:grid;grid-template-columns:repeat(2,minmax(150px,1fr));gap:var(--shd-gap-sm)}
.health-boundaries .field small{display:block;margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.3}
.predicate-fields{display:grid;grid-template-columns:minmax(170px,1fr) minmax(190px,.8fr);align-items:end;gap:var(--shd-gap-sm)}
.event-predicate-fields{grid-template-columns:minmax(180px,1fr) minmax(190px,.85fr) minmax(180px,.75fr) minmax(150px,.75fr)}
.predicate-fields>.field{margin:0}
.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(260px,1fr) minmax(220px,1fr) minmax(110px,.35fr) minmax(240px,1fr) 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}
.delivery-controls{display:grid;grid-template-columns:minmax(260px,1fr) minmax(150px,.35fr);align-items:end;gap:8px}.delivery-controls>.field{margin:0}
.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,.delivery-controls,.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}.condition-controls{grid-template-columns:1fr}.event-predicate-fields{grid-template-columns:repeat(2,minmax(0,1fr))}}
@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,.delivery-controls,.script-preview,.health-band-config>div,.health-boundaries,.predicate-fields,.event-predicate-fields{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>