1452 lines
77 KiB
Svelte
1452 lines
77 KiB
Svelte
<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,
|
||
eventFieldNumericExpression,
|
||
mechanicTargetingForMode,
|
||
mechanicTargetingMode,
|
||
parseConditionalMechanicDraft,
|
||
readCurrentHealthPercentBand,
|
||
readEventFieldComparison,
|
||
readEventFieldNumericExpression,
|
||
registeredEventFields,
|
||
readStatePoolThreshold,
|
||
statePoolThresholdPredicate,
|
||
type ConditionalMechanicClauseDraft,
|
||
type ConditionalNumericExpression,
|
||
type ConditionalMechanicDraftV1,
|
||
type ConditionalMechanicOutcomeDraft,
|
||
type ConditionalMechanicPoolDraft,
|
||
type EditablePredicateScalar,
|
||
type EventFieldComparison,
|
||
type MechanicTargetingMode,
|
||
type PredicateComparisonOperator,
|
||
type StatePoolThresholdComparison
|
||
} from '$lib/calculations/mechanic-editor';
|
||
import {
|
||
formatMechanicProgram,
|
||
MECHANIC_EVENT_KINDS,
|
||
MECHANIC_EVENT_REGISTRY,
|
||
PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_FIELD,
|
||
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) {
|
||
const field = selectedEventPredicateField(clause);
|
||
if (field?.path === 'values.aiming') return predicateOperatorOptions.filter((option) => option.value === 'eq');
|
||
return field?.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 outcomes = nextEvent === 'weapon_hit'
|
||
? clause.outcomes
|
||
: clause.outcomes.map((outcome) => outcome.targeting?.kind === 'event_target_area'
|
||
? { ...outcome, targeting: undefined }
|
||
: outcome);
|
||
const fields = registeredEventFields(nextEvent);
|
||
const comparison = readEventFieldComparison(clause.if);
|
||
if (!comparison || !fields.length) {
|
||
updateClause(clauseIndex, { event: nextEvent, outcomes });
|
||
return;
|
||
}
|
||
const field = fields[0];
|
||
updateClause(clauseIndex, {
|
||
event: nextEvent,
|
||
outcomes,
|
||
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 derivedScalarModel(clause: ConditionalMechanicClauseDraft) {
|
||
if (clause.reducer.kind !== 'derived_scalar') return 'static';
|
||
if (typeof clause.reducer.value === 'number') return 'static';
|
||
return readEventFieldNumericExpression(clause.reducer.value) ? 'event' : 'advanced';
|
||
}
|
||
|
||
function numericEventFields(clause: ConditionalMechanicClauseDraft) {
|
||
return registeredEventFields(clause.event).filter((field) => field.type === 'number');
|
||
}
|
||
|
||
function setDerivedScalarModel(clauseIndex: number, model: string) {
|
||
const clause = rule.clauses[clauseIndex];
|
||
if (clause.reducer.kind !== 'derived_scalar') return;
|
||
if (model === 'static') {
|
||
updateClause(clauseIndex, { reducer: { kind: 'derived_scalar', value: 1 } });
|
||
return;
|
||
}
|
||
const field = numericEventFields(clause)[0];
|
||
if (model === 'event' && field) {
|
||
updateClause(clauseIndex, {
|
||
reducer: {
|
||
kind: 'derived_scalar',
|
||
value: eventFieldNumericExpression(field.path)
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
function setDerivedScalarEventField(clauseIndex: number, path: string) {
|
||
updateClause(clauseIndex, {
|
||
reducer: { kind: 'derived_scalar', value: eventFieldNumericExpression(path) }
|
||
});
|
||
}
|
||
|
||
function updateOutcome(clauseIndex: number, outcomeIndex: number, patch: Partial<ConditionalMechanicOutcomeDraft>) {
|
||
const outcomes = [...rule.clauses[clauseIndex].outcomes];
|
||
outcomes[outcomeIndex] = { ...outcomes[outcomeIndex], ...patch };
|
||
updateClause(clauseIndex, { outcomes });
|
||
}
|
||
|
||
function targetingOptions(clause: ConditionalMechanicClauseDraft, outcome: ConditionalMechanicOutcomeDraft) {
|
||
if (outcome.scope === 'allies' || outcome.scope === 'self_and_allies') {
|
||
return [
|
||
{ value: 'none', label: 'NONE' },
|
||
{ value: 'allies_within_radius', label: 'ALLIES IN RADIUS' }
|
||
];
|
||
}
|
||
if (outcome.scope === 'target') {
|
||
return [
|
||
{ value: 'none', label: 'NONE' },
|
||
{ value: 'enemies_within_radius', label: 'ENEMIES IN RADIUS · LEGACY' },
|
||
...(outcome.kind === 'direct_damage' && clause.event === 'weapon_hit'
|
||
? [{ value: 'event_target_explosion', label: 'HIT-TARGET EXPLOSION' }]
|
||
: [])
|
||
];
|
||
}
|
||
return [{ value: 'none', label: 'NONE' }];
|
||
}
|
||
|
||
function setOutcomeScope(clauseIndex: number, outcomeIndex: number, scope: MechanicOutcomeScope) {
|
||
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
||
const targeting = outcome.targeting;
|
||
const compatible = !targeting
|
||
|| (targeting.kind === 'event_target_area' && scope === 'target' && outcome.kind === 'direct_damage')
|
||
|| (targeting.kind === 'within_radius' && targeting.population === 'enemies' && scope === 'target')
|
||
|| (targeting.kind === 'within_radius' && targeting.population === 'allies' && (scope === 'allies' || scope === 'self_and_allies'));
|
||
updateOutcome(clauseIndex, outcomeIndex, { scope, targeting: compatible ? targeting : undefined });
|
||
}
|
||
|
||
function setOutcomeKind(clauseIndex: number, outcomeIndex: number, kind: ConditionalMechanicOutcomeDraft['kind']) {
|
||
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
||
updateOutcome(clauseIndex, outcomeIndex, {
|
||
kind,
|
||
resource: kind === 'restoration' || kind === 'resource_cost' ? outcome.resource ?? 'health' : undefined,
|
||
attributeId: kind === 'attribute_modifier' ? outcome.attributeId ?? '' : undefined,
|
||
delivery: kind === 'restoration' ? outcome.delivery : undefined,
|
||
durationSeconds: kind === 'restoration' ? outcome.durationSeconds : undefined,
|
||
targeting: outcome.targeting?.kind === 'event_target_area' && kind !== 'direct_damage'
|
||
? undefined
|
||
: outcome.targeting
|
||
});
|
||
}
|
||
|
||
function setOutcomeTargeting(clauseIndex: number, outcomeIndex: number, mode: MechanicTargetingMode) {
|
||
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
||
updateOutcome(clauseIndex, outcomeIndex, {
|
||
targeting: mechanicTargetingForMode(mode, outcome.targeting),
|
||
phase: mode === 'event_target_explosion' && outcome.phase !== 'on_event' && outcome.phase !== 'after_event'
|
||
? 'after_event'
|
||
: outcome.phase
|
||
});
|
||
}
|
||
|
||
function phaseOptions(outcome: ConditionalMechanicOutcomeDraft) {
|
||
return [
|
||
{ value: 'on_event', label: 'ON EVENT' },
|
||
{ value: 'after_event', label: 'AFTER EVENT' },
|
||
{ value: 'while_active', label: 'WHILE ACTIVE', disabled: outcome.targeting?.kind === 'event_target_area' },
|
||
{ value: 'on_enter', label: 'ON ENTER', disabled: outcome.targeting?.kind === 'event_target_area' },
|
||
{ value: 'on_exit', label: 'ON EXIT', disabled: outcome.targeting?.kind === 'event_target_area' }
|
||
];
|
||
}
|
||
|
||
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: PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_FIELD,
|
||
label: 'AGENT-BUFFED ALLIES ≤30M'
|
||
},
|
||
{ 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(): ConditionalNumericExpression {
|
||
return {
|
||
op: 'multiply',
|
||
left: { ref: { source: 'variant_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.right === 'object'
|
||
) {
|
||
if (typeof expression.left === 'number' && '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 (
|
||
typeof expression.left === 'number'
|
||
&& '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 (
|
||
(typeof expression.left === 'number'
|
||
|| (
|
||
typeof expression.left === 'object'
|
||
&& 'ref' in expression.left
|
||
&& expression.left.ref.source === 'variant_value'
|
||
))
|
||
&& '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 = ''
|
||
): ConditionalNumericExpression | 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();
|
||
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: ConditionalNumericExpression, value: number): ConditionalNumericExpression {
|
||
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();
|
||
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 addAbilityDurationExtension(clauseIndex: number) {
|
||
updateClause(clauseIndex, {
|
||
mutations: [
|
||
...rule.clauses[clauseIndex].mutations,
|
||
{ kind: 'extend_duration', target: 'assigned_ability', seconds: 0.3 }
|
||
]
|
||
});
|
||
}
|
||
|
||
function addCooldownOnMax(clauseIndex: number) {
|
||
const stackPool = rule.pools.find((pool) => pool.max && pool.max > 0)?.key;
|
||
const cooldownPool = rule.pools.find((pool) => pool.scope === 'agent' && pool.expiresAfterSeconds)?.key;
|
||
if (!stackPool || !cooldownPool || stackPool === cooldownPool) return;
|
||
updateClause(clauseIndex, {
|
||
mutations: [
|
||
...rule.clauses[clauseIndex].mutations,
|
||
{ kind: 'start_cooldown_on_max', stackPool, cooldownPool }
|
||
]
|
||
});
|
||
}
|
||
|
||
function updateAbilityDurationExtension(clauseIndex: number, mutationIndex: number, raw: string) {
|
||
const mutations = [...rule.clauses[clauseIndex].mutations];
|
||
const seconds = Number(raw);
|
||
mutations[mutationIndex] = {
|
||
kind: 'extend_duration',
|
||
target: 'assigned_ability',
|
||
seconds: Number.isFinite(seconds) ? seconds : 0
|
||
};
|
||
updateClause(clauseIndex, { mutations });
|
||
}
|
||
|
||
function updateCooldownOnMax(
|
||
clauseIndex: number,
|
||
mutationIndex: number,
|
||
patch: { stackPool?: string; cooldownPool?: string }
|
||
) {
|
||
const mutations = [...rule.clauses[clauseIndex].mutations];
|
||
const current = mutations[mutationIndex];
|
||
if (current.kind !== 'start_cooldown_on_max') return;
|
||
mutations[mutationIndex] = { ...current, ...patch };
|
||
updateClause(clauseIndex, { mutations });
|
||
}
|
||
|
||
function updateMutation(clauseIndex: number, mutationIndex: number, kind: 'add' | 'set' | 'consume' | 'derive' | '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 === 'derive'
|
||
? { kind, pool: nextPool, value: { ref: { source: 'reducer' } }, 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}
|
||
{#if allowAssignedAbility}
|
||
<div class="ability-proc-controls">
|
||
<SegmentedChoice
|
||
legend="ASSIGNED ABILITY"
|
||
name={`${idPrefix}-assigned-ability-${clauseIndex}`}
|
||
options={[{value:'false',label:'ANY STATE'},{value:'true',label:'MUST BE ACTIVE'}]}
|
||
value={String(clause.requiresAssignedAbilityActive ?? false)}
|
||
onchange={(values) => updateClause(clauseIndex,{requiresAssignedAbilityActive:values[0] === 'true' ? true : undefined})}
|
||
/>
|
||
<label class="field">PROC CHANCE · %<input type="number" min="0" max="100" step="any" value={clause.procChancePercent ?? ''} oninput={(event) => updateClause(clauseIndex,{procChancePercent:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/><small>Rolls after conditions and cooldown checks, before mutations.</small></label>
|
||
</div>
|
||
{:else}
|
||
<label class="field proc-chance-field">PROC CHANCE · %<input type="number" min="0" max="100" step="any" value={clause.procChancePercent ?? ''} oninput={(event) => updateClause(clauseIndex,{procChancePercent:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/><small>Rolls after conditions and cooldown checks, before mutations.</small></label>
|
||
{/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'}
|
||
<SegmentedChoice
|
||
legend="DERIVED VALUE SOURCE"
|
||
name={`${idPrefix}-derived-source-${clauseIndex}`}
|
||
options={[
|
||
{value:'static',label:'STATIC'},
|
||
{value:'event',label:'EVENT FIELD',disabled:numericEventFields(clause).length === 0},
|
||
...(derivedScalarModel(clause) === 'advanced' ? [{value:'advanced',label:'ADVANCED',disabled:true}] : [])
|
||
]}
|
||
value={derivedScalarModel(clause)}
|
||
onchange={(values) => setDerivedScalarModel(clauseIndex,values[0])}
|
||
/>
|
||
{#if derivedScalarModel(clause) === 'event'}
|
||
<SegmentedChoice
|
||
legend="NUMERIC EVENT FIELD"
|
||
name={`${idPrefix}-derived-event-${clauseIndex}`}
|
||
options={numericEventFields(clause).map((field) => ({value:field.path,label:field.label.toUpperCase()}))}
|
||
value={readEventFieldNumericExpression(clause.reducer.value) ?? ''}
|
||
columns={Math.min(3,numericEventFields(clause).length)}
|
||
onchange={(values) => setDerivedScalarEventField(clauseIndex,values[0])}
|
||
/>
|
||
{:else if derivedScalarModel(clause) === 'static'}
|
||
<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}
|
||
{/if}
|
||
</div>
|
||
|
||
<div class="mutation-panel">
|
||
<div class="outcome-heading"><div><span>STATE CHANGES</span><strong>{clause.mutations.length}</strong></div><div class="mutation-actions"><button type="button" class="secondary compact-action" onclick={() => addMutation(clauseIndex)} disabled={!rule.pools.length}>ADD STATE CHANGE +</button>{#if allowAssignedAbility}<button type="button" class="secondary compact-action" onclick={() => addAbilityDurationExtension(clauseIndex)}>EXTEND ABILITY +</button>{/if}<button type="button" class="secondary compact-action" onclick={() => addCooldownOnMax(clauseIndex)} disabled={rule.pools.filter((pool) => pool.max && pool.max > 0).length < 2}>ADD MAX COOLDOWN +</button></div></div>
|
||
{#each clause.mutations as mutation, mutationIndex}
|
||
{#if mutation.kind === 'extend_duration'}
|
||
<div class="mutation-row ability-mutation-row">
|
||
<div class="branch-readout"><span>OPERATION</span><strong>EXTEND ASSIGNED ABILITY</strong></div>
|
||
<label class="field">SECONDS<input type="number" min="0" step="any" value={typeof mutation.seconds === 'number' ? mutation.seconds : 0} oninput={(event) => updateAbilityDurationExtension(clauseIndex,mutationIndex,event.currentTarget.value)}/><small>Applies only while the assigned ability remains active.</small></label>
|
||
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
|
||
</div>
|
||
{:else if mutation.kind === 'start_cooldown_on_max'}
|
||
<div class="mutation-row cooldown-mutation-row">
|
||
<div class="branch-readout"><span>OPERATION</span><strong>START COOLDOWN ON MAX</strong></div>
|
||
<SegmentedChoice legend="STACK POOL" name={`${idPrefix}-cooldown-stack-${clauseIndex}-${mutationIndex}`} options={rule.pools.filter((pool) => pool.max && pool.max > 0).map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))} value={mutation.stackPool} onchange={(values) => updateCooldownOnMax(clauseIndex,mutationIndex,{stackPool:values[0]})}/>
|
||
<SegmentedChoice legend="COOLDOWN POOL" name={`${idPrefix}-cooldown-pool-${clauseIndex}-${mutationIndex}`} options={rule.pools.filter((pool) => pool.scope === 'agent' && pool.expiresAfterSeconds && pool.key !== mutation.stackPool).map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))} value={mutation.cooldownPool} onchange={(values) => updateCooldownOnMax(clauseIndex,mutationIndex,{cooldownPool:values[0]})}/>
|
||
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
|
||
</div>
|
||
{:else}
|
||
<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:'derive',label:'DERIVED VALUE'},{value:'clear',label:'CLEAR'}]}
|
||
value={mutation.kind === 'none' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
|
||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'derive'|'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 === 'derive' ? 'derive' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])}
|
||
/>
|
||
{#if mutation.kind !== 'clear' && mutation.kind !== 'derive'}
|
||
<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>
|
||
{:else if mutation.kind === 'derive'}
|
||
<div class="branch-readout"><span>VALUE</span><strong>DERIVED SCALAR</strong></div>
|
||
{/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>
|
||
{/if}
|
||
{: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) => setOutcomeKind(clauseIndex,outcomeIndex,values[0] as ConditionalMechanicOutcomeDraft['kind'])}/>
|
||
<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) => setOutcomeScope(clauseIndex,outcomeIndex,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={targetingOptions(clause,outcome)}
|
||
value={mechanicTargetingMode(outcome.targeting)}
|
||
onchange={(values) => setOutcomeTargeting(clauseIndex,outcomeIndex,values[0] as MechanicTargetingMode)}
|
||
/>
|
||
{#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 outcome.targeting.kind === 'event_target_area'}
|
||
<p class="area-targeting-note"><strong>EVENT TARGET CENTER</strong><span>Damages each valid enemy at or inside the radius once. Runtime events must supply the authoritative target sample.</span></p>
|
||
{/if}
|
||
{/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>
|
||
<label class="field probability-field">OUTCOME CHANCE · %<input type="number" min="0" max="100" step="any" value={outcome.probabilityPercent ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{probabilityPercent:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/><small>Each eligible outcome rolls independently. Maximum Potential resolves each nonzero chance as successful.</small></label>
|
||
<div class="outcome-footer">
|
||
<SegmentedChoice legend="PHASE" name={`${idPrefix}-phase-${clauseIndex}-${outcomeIndex}`} options={phaseOptions(outcome)} 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)}
|
||
.ability-proc-controls{display:grid;grid-template-columns:minmax(240px,.7fr) minmax(180px,.4fr);align-items:end;gap:var(--shd-gap-sm)}
|
||
.ability-proc-controls .field small,.proc-chance-field small,.probability-field small,.ability-mutation-row .field small{display:block;margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.3}
|
||
.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-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}
|
||
.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)}
|
||
.ability-mutation-row{grid-template-columns:minmax(240px,1fr) minmax(180px,.5fr) auto}.cooldown-mutation-row{grid-template-columns:minmax(210px,.8fr) minmax(220px,1fr) minmax(220px,1fr) auto}.probability-field{max-width:260px}
|
||
.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}
|
||
.area-targeting-note{grid-column:1/-1;display:grid;gap:2px;margin:0;padding:7px 9px;border-left:2px solid var(--shd-info);background:var(--shd-model-surface);color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.area-targeting-note strong{color:var(--shd-info);font-size:var(--shd-type-telemetry);letter-spacing:.7px}
|
||
.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>
|