feat: add source-owned combat mechanic programs
This commit is contained in:
@@ -0,0 +1,735 @@
|
||||
<script lang="ts">
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import MechanicLanguageHelp from '$lib/components/MechanicLanguageHelp.svelte';
|
||||
import PseudoScript from '$lib/components/PseudoScript.svelte';
|
||||
import {
|
||||
blankConditionalMechanicDraft,
|
||||
parseConditionalMechanicDraft,
|
||||
type ConditionalMechanicClauseDraft,
|
||||
type ConditionalMechanicDraftV1,
|
||||
type ConditionalMechanicOutcomeDraft,
|
||||
type ConditionalMechanicPoolDraft
|
||||
} from '$lib/calculations/mechanic-editor';
|
||||
import {
|
||||
formatMechanicProgram,
|
||||
MECHANIC_EVENT_KINDS,
|
||||
MECHANIC_EVENT_REGISTRY,
|
||||
type ClauseMutation,
|
||||
type MechanicOutcomeScope,
|
||||
type NumericExpression,
|
||||
type OutcomeBasis,
|
||||
type OutcomePhase,
|
||||
type Reducer,
|
||||
type ScenarioInputDefinition
|
||||
} from '$lib/calculations/mechanic';
|
||||
import {
|
||||
TRIGGER_ACTION_KEYS,
|
||||
TRIGGER_ACTION_REGISTRY,
|
||||
type TriggerActionKey
|
||||
} from '$lib/calculations/conditional';
|
||||
import { createMechanicRecipe } from '$lib/calculations/mechanic-recipes';
|
||||
|
||||
export type MechanicValueColumn = { key: string; variantKey: string; label: string };
|
||||
type Attribute = { id: string; name: string; unit?: string };
|
||||
|
||||
let {
|
||||
rule = $bindable(blankConditionalMechanicDraft()),
|
||||
attributes,
|
||||
valueColumns,
|
||||
idPrefix = 'mechanic',
|
||||
allowAssignedAbility = false,
|
||||
allowProtocolUpgradeScaling = false,
|
||||
disabled = false
|
||||
}: {
|
||||
rule?: ConditionalMechanicDraftV1;
|
||||
attributes: Attribute[];
|
||||
valueColumns: MechanicValueColumn[];
|
||||
idPrefix?: string;
|
||||
allowAssignedAbility?: boolean;
|
||||
allowProtocolUpgradeScaling?: boolean;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
let recipeKey = $state<TriggerActionKey>('same_target_hits_within_window');
|
||||
let recipeCount = $state(9);
|
||||
let recipeWindow = $state(3);
|
||||
let recipeDistance = $state(10);
|
||||
let replaceConfirmation = $state(false);
|
||||
|
||||
const readinessOptions = [
|
||||
{ value: 'ready', label: 'READY' },
|
||||
{ value: 'requires_assumptions', label: 'ASSUMPTIONS' },
|
||||
{ value: 'captured_only', label: 'CAPTURE ONLY' }
|
||||
];
|
||||
const eventItems = MECHANIC_EVENT_KINDS.map((id) => ({
|
||||
id,
|
||||
name: MECHANIC_EVENT_REGISTRY[id].label
|
||||
}));
|
||||
const reducerItems = [
|
||||
{ id: 'none', name: 'Every matching event' },
|
||||
{ id: 'count', name: 'Count matches' },
|
||||
{ id: 'distinct_count', name: 'Count distinct targets' },
|
||||
{ id: 'consecutive_count', name: 'Count consecutive matches' },
|
||||
{ id: 'sliding_count', name: 'Count within a time window' },
|
||||
{ id: 'duration', name: 'Condition held for a duration' },
|
||||
{ id: 'periodic', name: 'Repeat on an interval' },
|
||||
{ id: 'derived_scalar', name: 'Derive a scalar value' }
|
||||
];
|
||||
const recipeItems = TRIGGER_ACTION_KEYS.map((id) => ({
|
||||
id,
|
||||
name: TRIGGER_ACTION_REGISTRY[id].label
|
||||
}));
|
||||
const outcomeKinds = [
|
||||
{ value: 'attribute_modifier', label: 'ATTRIBUTE' },
|
||||
{ value: 'direct_damage', label: 'DAMAGE' },
|
||||
{ value: 'restoration', label: 'RESTORE' },
|
||||
{ value: 'resource_cost', label: 'COST' },
|
||||
{ value: 'marker', label: 'MARKER' }
|
||||
];
|
||||
const scopeOptions = $derived([
|
||||
{ value: 'self', label: 'SELF' },
|
||||
{ value: 'equipped_weapon', label: 'WEAPON' },
|
||||
{ value: 'target', label: 'TARGET' },
|
||||
{ value: 'allies', label: 'ALLIES' },
|
||||
{ value: 'self_and_allies', label: 'SELF + ALLIES' },
|
||||
...(allowAssignedAbility ? [{ value: 'assigned_ability', label: 'ABILITY' }] : []),
|
||||
{ value: 'all_skills', label: 'ALL SKILLS' }
|
||||
]);
|
||||
|
||||
const slug = (value: string) => value.toLocaleLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'branch';
|
||||
const clone = <T,>(value: T): T => structuredClone(value);
|
||||
const setRule = (next: ConditionalMechanicDraftV1) => { rule = next; };
|
||||
|
||||
function recipeParams() {
|
||||
if (recipeKey === 'same_target_hits_within_window') return { hitsRequired: recipeCount, windowSeconds: recipeWindow };
|
||||
if (recipeKey === 'distinct_enemies_within_window') return { enemiesRequired: recipeCount, windowSeconds: recipeWindow };
|
||||
if (recipeKey === 'consecutive_same_target_hits') return { hitsRequired: recipeCount };
|
||||
if (recipeKey === 'continuous_firing_duration') return { seconds: recipeWindow };
|
||||
if (recipeKey === 'target_hit_within_distance' || recipeKey === 'within_distance') return { meters: recipeDistance };
|
||||
if (recipeKey === 'health_threshold_bands') return { bands: [{ thresholdPercent: 50, outcomeKey: 'active' }] };
|
||||
return {};
|
||||
}
|
||||
|
||||
function applyRecipe(replace = false) {
|
||||
const result = createMechanicRecipe(recipeKey, recipeParams() as never);
|
||||
if (result.status !== 'supported') return;
|
||||
const clauses = result.seed.clauses.map((seed, index): ConditionalMechanicClauseDraft => ({
|
||||
key: `${slug(recipeKey)}-${rule.clauses.length + index + 1}`,
|
||||
label: seed.label ?? TRIGGER_ACTION_REGISTRY[recipeKey].label,
|
||||
event: seed.when,
|
||||
recipeKey,
|
||||
if: seed.if ?? { kind: 'true' },
|
||||
reducer: seed.reducer,
|
||||
cooldownSeconds: seed.cooldownSeconds,
|
||||
weight: seed.weight,
|
||||
mutations: seed.mutations,
|
||||
outcomes: []
|
||||
}));
|
||||
setRule({
|
||||
...rule,
|
||||
scenarioInputs: replace
|
||||
? clone(result.seed.scenarioInputs ?? [])
|
||||
: [...rule.scenarioInputs, ...clone(result.seed.scenarioInputs ?? [])],
|
||||
pools: replace
|
||||
? (result.seed.pools ?? []).map((pool) => ({ ...clone(pool), stateKind: 'stack' as const }))
|
||||
: [
|
||||
...rule.pools,
|
||||
...(result.seed.pools ?? [])
|
||||
.filter((pool) => !rule.pools.some((current) => current.key === pool.key))
|
||||
.map((pool) => ({ ...clone(pool), stateKind: 'stack' as const }))
|
||||
],
|
||||
clauses: replace ? clauses : [...rule.clauses, ...clauses]
|
||||
});
|
||||
replaceConfirmation = false;
|
||||
}
|
||||
|
||||
function blankClause(): ConditionalMechanicClauseDraft {
|
||||
const number = rule.clauses.length + 1;
|
||||
return {
|
||||
key: `branch-${number}`,
|
||||
label: `Branch ${number}`,
|
||||
event: 'weapon_hit',
|
||||
if: { kind: 'true' },
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: []
|
||||
};
|
||||
}
|
||||
|
||||
function blankOutcome(index: number): ConditionalMechanicOutcomeDraft {
|
||||
return {
|
||||
key: `effect-${index + 1}`,
|
||||
label: `Effect ${index + 1}`,
|
||||
phase: 'while_active',
|
||||
kind: 'attribute_modifier',
|
||||
scope: 'self',
|
||||
basis: 'flat',
|
||||
perStack: true,
|
||||
descriptionSlot: index + 1,
|
||||
attributeId: '',
|
||||
variants: Object.fromEntries(valueColumns.map((column) => [
|
||||
column.variantKey,
|
||||
{ value: 0 }
|
||||
]))
|
||||
};
|
||||
}
|
||||
|
||||
function updateClause(index: number, patch: Partial<ConditionalMechanicClauseDraft>) {
|
||||
const clauses = [...rule.clauses];
|
||||
clauses[index] = { ...clauses[index], ...patch };
|
||||
setRule({ ...rule, clauses });
|
||||
}
|
||||
|
||||
function reducerKind(index: number, kind: string) {
|
||||
const reducer: Reducer =
|
||||
kind === 'count' ? { kind: 'count', threshold: 1 }
|
||||
: kind === 'distinct_count' ? { kind: 'distinct_count', threshold: 1, windowSeconds: 1, distinctBy: { source: 'event', path: 'targetId' } }
|
||||
: kind === 'consecutive_count' ? { kind: 'consecutive_count', threshold: 1, groupBy: { source: 'event', path: 'targetId' } }
|
||||
: kind === 'sliding_count' ? { kind: 'sliding_count', threshold: 1, windowSeconds: 1 }
|
||||
: kind === 'duration' ? { kind: 'duration', seconds: 1 }
|
||||
: kind === 'periodic' ? { kind: 'periodic', intervalSeconds: 1 }
|
||||
: kind === 'derived_scalar' ? { kind: 'derived_scalar', value: 1 }
|
||||
: { kind: 'none' };
|
||||
updateClause(index, { reducer });
|
||||
}
|
||||
|
||||
function setReducerNumber(index: number, property: string, raw: string) {
|
||||
const value = Number(raw);
|
||||
const reducer = { ...rule.clauses[index].reducer, [property]: Number.isFinite(value) ? value : 0 } as Reducer;
|
||||
updateClause(index, { reducer });
|
||||
}
|
||||
|
||||
function updateOutcome(clauseIndex: number, outcomeIndex: number, patch: Partial<ConditionalMechanicOutcomeDraft>) {
|
||||
const outcomes = [...rule.clauses[clauseIndex].outcomes];
|
||||
outcomes[outcomeIndex] = { ...outcomes[outcomeIndex], ...patch };
|
||||
updateClause(clauseIndex, { outcomes });
|
||||
}
|
||||
|
||||
function updateVariant(clauseIndex: number, outcomeIndex: number, variant: string, raw: string) {
|
||||
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
||||
const value = Number(raw);
|
||||
const previous = outcome.variants[variant]?.expression;
|
||||
updateOutcome(clauseIndex, outcomeIndex, {
|
||||
variants: {
|
||||
...outcome.variants,
|
||||
[variant]: {
|
||||
...outcome.variants[variant],
|
||||
value,
|
||||
...(previous === undefined ? {} : { expression: replaceExpressionBase(previous, value) })
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type OutcomeValueModel =
|
||||
| 'static'
|
||||
| 'event'
|
||||
| 'reducer'
|
||||
| 'scenario'
|
||||
| 'protocol_upgrades'
|
||||
| 'advanced';
|
||||
|
||||
const valueModelOptions = $derived([
|
||||
{ value: 'static', label: 'STATIC' },
|
||||
{ value: 'event', label: 'EVENT FIELD' },
|
||||
{ value: 'reducer', label: 'REDUCER' },
|
||||
{ value: 'scenario', label: 'SCENARIO' },
|
||||
...(allowProtocolUpgradeScaling
|
||||
? [{ value: 'protocol_upgrades', label: 'PROTOCOL UPGRADES' }]
|
||||
: [])
|
||||
]);
|
||||
const eventValueOptions = [
|
||||
{ value: 'values.enemyCount', label: 'ENEMY COUNT' },
|
||||
{ value: 'values.allyCount', label: 'ALLY COUNT' },
|
||||
{ value: 'values.magazineRemaining', label: 'ROUNDS LEFT' },
|
||||
{ value: 'values.magazinePercent', label: 'MAGAZINE %' },
|
||||
{ value: 'values.distanceMeters', label: 'DISTANCE' }
|
||||
];
|
||||
|
||||
function referenceExpression(source: 'event' | 'scenario', path: string, value: number): NumericExpression {
|
||||
return {
|
||||
op: 'multiply',
|
||||
left: value,
|
||||
right: {
|
||||
ref: source === 'event'
|
||||
? { source, path }
|
||||
: { source, key: path }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function protocolUpgradeExpression(value: number): NumericExpression {
|
||||
return {
|
||||
op: 'multiply',
|
||||
left: value,
|
||||
right: {
|
||||
op: 'add',
|
||||
left: 1,
|
||||
right: {
|
||||
op: 'divide',
|
||||
left: { ref: { source: 'equipment', path: 'protocolTalentUpgrades' } },
|
||||
right: 5
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function outcomeValueModel(outcome: ConditionalMechanicOutcomeDraft): OutcomeValueModel {
|
||||
const expression = Object.values(outcome.variants)[0]?.expression;
|
||||
if (expression === undefined) return 'static';
|
||||
if (
|
||||
typeof expression === 'object'
|
||||
&& 'op' in expression
|
||||
&& expression.op === 'multiply'
|
||||
&& typeof expression.left === 'number'
|
||||
&& typeof expression.right === 'object'
|
||||
) {
|
||||
if ('ref' in expression.right) {
|
||||
if (expression.right.ref.source === 'event') return 'event';
|
||||
if (expression.right.ref.source === 'scenario') return 'scenario';
|
||||
if (expression.right.ref.source === 'reducer') return 'reducer';
|
||||
}
|
||||
if (
|
||||
'op' in expression.right
|
||||
&& expression.right.op === 'add'
|
||||
&& typeof expression.right.right === 'object'
|
||||
&& 'op' in expression.right.right
|
||||
&& expression.right.right.op === 'divide'
|
||||
&& typeof expression.right.right.left === 'object'
|
||||
&& 'ref' in expression.right.right.left
|
||||
&& expression.right.right.left.ref.source === 'equipment'
|
||||
&& expression.right.right.left.ref.path === 'protocolTalentUpgrades'
|
||||
) return 'protocol_upgrades';
|
||||
}
|
||||
return 'advanced';
|
||||
}
|
||||
|
||||
function outcomeReference(outcome: ConditionalMechanicOutcomeDraft, source: 'event' | 'scenario') {
|
||||
const expression = Object.values(outcome.variants)[0]?.expression;
|
||||
if (
|
||||
expression
|
||||
&& typeof expression === 'object'
|
||||
&& 'op' in expression
|
||||
&& expression.op === 'multiply'
|
||||
&& typeof expression.right === 'object'
|
||||
&& 'ref' in expression.right
|
||||
&& expression.right.ref.source === source
|
||||
) {
|
||||
if (expression.right.ref.source === 'event') return expression.right.ref.path;
|
||||
if (expression.right.ref.source === 'scenario') return expression.right.ref.key;
|
||||
}
|
||||
return source === 'event' ? 'values.enemyCount' : rule.scenarioInputs[0]?.key ?? '';
|
||||
}
|
||||
|
||||
function expressionForModel(
|
||||
model: OutcomeValueModel,
|
||||
value: number,
|
||||
reference = ''
|
||||
): NumericExpression | undefined {
|
||||
if (model === 'event') return referenceExpression('event', reference || 'values.enemyCount', value);
|
||||
if (model === 'scenario') return reference
|
||||
? referenceExpression('scenario', reference, value)
|
||||
: undefined;
|
||||
if (model === 'reducer') {
|
||||
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
|
||||
}
|
||||
if (model === 'protocol_upgrades') return protocolUpgradeExpression(value);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function setOutcomeValueModel(
|
||||
clauseIndex: number,
|
||||
outcomeIndex: number,
|
||||
model: OutcomeValueModel,
|
||||
reference = ''
|
||||
) {
|
||||
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
||||
const variants = Object.fromEntries(Object.entries(outcome.variants).map(([key, variant]) => {
|
||||
const expression = expressionForModel(model, Number(variant.value), reference);
|
||||
return [key, expression === undefined ? { value: Number(variant.value) } : {
|
||||
value: Number(variant.value),
|
||||
expression
|
||||
}];
|
||||
}));
|
||||
updateOutcome(clauseIndex, outcomeIndex, { variants });
|
||||
}
|
||||
|
||||
function replaceExpressionBase(expression: NumericExpression, value: number): NumericExpression {
|
||||
const model = outcomeValueModel({
|
||||
key: 'temporary',
|
||||
label: 'Temporary',
|
||||
phase: 'on_event',
|
||||
kind: 'marker',
|
||||
scope: 'self',
|
||||
basis: 'flat',
|
||||
variants: { base: { value, expression } }
|
||||
});
|
||||
if (model === 'event') {
|
||||
return referenceExpression('event', (
|
||||
expression as { right: { ref: { path: string } } }
|
||||
).right.ref.path, value);
|
||||
}
|
||||
if (model === 'scenario') {
|
||||
return referenceExpression('scenario', (
|
||||
expression as { right: { ref: { key: string } } }
|
||||
).right.ref.key, value);
|
||||
}
|
||||
if (model === 'reducer') {
|
||||
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
|
||||
}
|
||||
if (model === 'protocol_upgrades') return protocolUpgradeExpression(value);
|
||||
return expression;
|
||||
}
|
||||
|
||||
function addMutation(clauseIndex: number) {
|
||||
const pool = rule.pools[0]?.key;
|
||||
if (!pool) return;
|
||||
updateClause(clauseIndex, {
|
||||
mutations: [...rule.clauses[clauseIndex].mutations, { kind: 'add', pool, value: 1 }]
|
||||
});
|
||||
}
|
||||
|
||||
function updateMutation(clauseIndex: number, mutationIndex: number, kind: 'add' | 'set' | 'consume' | 'clear', pool?: string, value?: number) {
|
||||
const mutations = [...rule.clauses[clauseIndex].mutations];
|
||||
const current = mutations[mutationIndex];
|
||||
const nextPool = pool ?? ('pool' in current ? current.pool : rule.pools[0]?.key ?? '');
|
||||
mutations[mutationIndex] = kind === 'clear'
|
||||
? { kind, pool: nextPool }
|
||||
: { kind, pool: nextPool, value: value ?? ('value' in current && typeof current.value === 'number' ? current.value : 1) };
|
||||
updateClause(clauseIndex, { mutations });
|
||||
}
|
||||
|
||||
function updatePool(index: number, patch: Partial<ConditionalMechanicPoolDraft>) {
|
||||
const pools = [...rule.pools];
|
||||
pools[index] = { ...pools[index], ...patch };
|
||||
setRule({ ...rule, pools });
|
||||
}
|
||||
|
||||
function updateScenarioInput(index: number, patch: Partial<ScenarioInputDefinition>) {
|
||||
const scenarioInputs = [...rule.scenarioInputs];
|
||||
scenarioInputs[index] = { ...scenarioInputs[index], ...patch };
|
||||
setRule({ ...rule, scenarioInputs });
|
||||
}
|
||||
|
||||
function addScenarioInput() {
|
||||
const number = rule.scenarioInputs.length + 1;
|
||||
setRule({
|
||||
...rule,
|
||||
scenarioInputs: [
|
||||
...rule.scenarioInputs,
|
||||
{
|
||||
key: `assumption-${number}`,
|
||||
label: `Assumption ${number}`,
|
||||
kind: 'slider',
|
||||
readiness: 'requires_assumption',
|
||||
description: '',
|
||||
defaultValue: 0,
|
||||
bestCaseValue: 100,
|
||||
min: 0,
|
||||
max: 100
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
const validation = $derived(parseConditionalMechanicDraft(rule, {
|
||||
variantKeys: valueColumns.map((column) => column.variantKey),
|
||||
allowedAttributeIds: new Set(attributes.map((attribute) => attribute.id))
|
||||
}));
|
||||
const scriptLines = $derived(validation.ok
|
||||
? formatMechanicProgram(validation.value.programs[valueColumns[0]?.variantKey]).split('\n')
|
||||
: ['MECHANIC INCOMPLETE', '', ...validation.errors.map((error) => `ERROR ${error}`)]);
|
||||
const readinessLabel = $derived(
|
||||
rule.simulationReadiness === 'ready'
|
||||
? 'READY'
|
||||
: rule.simulationReadiness === 'requires_assumptions'
|
||||
? 'REQUIRES ASSUMPTIONS'
|
||||
: 'CAPTURED ONLY'
|
||||
);
|
||||
</script>
|
||||
|
||||
<fieldset class="mechanic-editor" {disabled}>
|
||||
<legend>MECHANIC PROGRAM</legend>
|
||||
|
||||
<div class="mechanic-heading">
|
||||
<div>
|
||||
<span>STRUCTURED COMBAT LOGIC</span>
|
||||
<strong>{rule.clauses.length} {rule.clauses.length === 1 ? 'BRANCH' : 'BRANCHES'}</strong>
|
||||
<p>Events are code-defined. Every branch, condition, state change, and outcome belongs to this item.</p>
|
||||
</div>
|
||||
<MechanicLanguageHelp />
|
||||
</div>
|
||||
|
||||
<SegmentedChoice
|
||||
legend="SIMULATION READINESS"
|
||||
name={`${idPrefix}-readiness`}
|
||||
options={readinessOptions}
|
||||
bind:value={rule.simulationReadiness}
|
||||
/>
|
||||
|
||||
<section class="scenario-panel">
|
||||
<header><div><span>BUILDER CONTROLS</span><strong>SCENARIO ASSUMPTIONS</strong></div><button type="button" class="secondary compact-action" onclick={addScenarioInput}>ADD CONTROL +</button></header>
|
||||
<p>These controls appear in a build’s advanced mechanic settings. Maximum Potential uses the best-case value; Custom Scenario uses the Agent’s saved value.</p>
|
||||
{#each rule.scenarioInputs as input, index}
|
||||
<div class="scenario-row">
|
||||
<label class="field">LABEL<input value={input.label} oninput={(event) => updateScenarioInput(index,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label>
|
||||
<SegmentedChoice legend="CONTROL TYPE" name={`${idPrefix}-scenario-kind-${index}`} options={[{value:'toggle',label:'TOGGLE'},{value:'slider',label:'SLIDER'},{value:'number',label:'NUMBER'}]} value={input.kind} onchange={(values) => updateScenarioInput(index,{kind:values[0] as ScenarioInputDefinition['kind'],defaultValue:values[0] === 'toggle' ? false : Number(input.defaultValue ?? 0),bestCaseValue:values[0] === 'toggle' ? true : Number(input.bestCaseValue ?? 100),min:values[0] === 'toggle' ? undefined : input.min ?? 0,max:values[0] === 'toggle' ? undefined : input.max ?? 100})}/>
|
||||
<label class="field description-field">AGENT GUIDANCE<input value={input.description ?? ''} placeholder="Explain what the Agent should estimate." oninput={(event) => updateScenarioInput(index,{description:event.currentTarget.value})}/></label>
|
||||
{#if input.kind === 'toggle'}
|
||||
<SegmentedChoice legend="DEFAULT" name={`${idPrefix}-scenario-default-${index}`} options={[{value:'false',label:'OFF'},{value:'true',label:'ON'}]} value={String(input.defaultValue ?? false)} onchange={(values) => updateScenarioInput(index,{defaultValue:values[0] === 'true'})}/>
|
||||
<SegmentedChoice legend="BEST CASE" name={`${idPrefix}-scenario-best-${index}`} options={[{value:'false',label:'OFF'},{value:'true',label:'ON'}]} value={String(input.bestCaseValue ?? true)} onchange={(values) => updateScenarioInput(index,{bestCaseValue:values[0] === 'true'})}/>
|
||||
{:else}
|
||||
<label class="field">MIN<input type="number" step="any" value={input.min ?? 0} oninput={(event) => updateScenarioInput(index,{min:Number(event.currentTarget.value)})}/></label>
|
||||
<label class="field">MAX<input type="number" step="any" value={input.max ?? 100} oninput={(event) => updateScenarioInput(index,{max:Number(event.currentTarget.value)})}/></label>
|
||||
<label class="field">DEFAULT<input type="number" step="any" value={Number(input.defaultValue ?? 0)} oninput={(event) => updateScenarioInput(index,{defaultValue:Number(event.currentTarget.value)})}/></label>
|
||||
<label class="field">BEST CASE<input type="number" step="any" value={Number(input.bestCaseValue ?? input.max ?? 100)} oninput={(event) => updateScenarioInput(index,{bestCaseValue:Number(event.currentTarget.value)})}/></label>
|
||||
{/if}
|
||||
<button type="button" class="remove" onclick={() => setRule({...rule,scenarioInputs:rule.scenarioInputs.filter((_,current) => current !== index)})}>REMOVE</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty-state">No source-specific assumptions. This mechanic uses only the shared encounter timeline.</p>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section class="recipe-panel">
|
||||
<header><div><span>RECIPE STARTER</span><strong>SEED AN EDITABLE BRANCH</strong></div><small>Recipes copy typed data into this item; they are not shared runtime dependencies.</small></header>
|
||||
<div class="recipe-grid">
|
||||
<SearchableAssignment
|
||||
items={recipeItems}
|
||||
mode="single"
|
||||
compact
|
||||
subjectLabel="RECIPE"
|
||||
bind:value={recipeKey}
|
||||
/>
|
||||
{#if ['same_target_hits_within_window', 'distinct_enemies_within_window', 'consecutive_same_target_hits'].includes(recipeKey)}
|
||||
<label class="field">COUNT<input type="number" min="1" step="1" bind:value={recipeCount}/></label>
|
||||
{/if}
|
||||
{#if ['same_target_hits_within_window', 'distinct_enemies_within_window', 'continuous_firing_duration'].includes(recipeKey)}
|
||||
<label class="field">SECONDS<input type="number" min="0.01" step="any" bind:value={recipeWindow}/></label>
|
||||
{/if}
|
||||
{#if ['target_hit_within_distance', 'within_distance'].includes(recipeKey)}
|
||||
<label class="field">METERS<input type="number" min="0" step="any" bind:value={recipeDistance}/></label>
|
||||
{/if}
|
||||
<button type="button" class="secondary" onclick={() => applyRecipe(false)}>ADD RECIPE BRANCH +</button>
|
||||
{#if rule.clauses.length}
|
||||
<button type="button" class="danger-quiet" onclick={() => replaceConfirmation = true}>REPLACE PROGRAM</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if replaceConfirmation}
|
||||
<div class="replace-confirm" role="alert">
|
||||
<p><strong>REPLACE ALL CURRENT BRANCHES?</strong><span>This keeps no existing program data.</span></p>
|
||||
<div><button type="button" class="secondary" onclick={() => replaceConfirmation = false}>CANCEL</button><button type="button" class="primary" onclick={() => applyRecipe(true)}>CONFIRM REPLACE</button></div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="state-panel">
|
||||
<header><div><span>STATE</span><strong>POOLS</strong></div><button type="button" class="secondary compact-action" onclick={() => setRule({...rule,pools:[...rule.pools,{key:`state-${rule.pools.length + 1}`,label:`State ${rule.pools.length + 1}`,stateKind:'stack',scope:'agent',initial:0,min:0,max:1,bestCaseValue:1}]})}>ADD POOL +</button></header>
|
||||
{#each rule.pools as pool, index}
|
||||
<div class="pool-row">
|
||||
<label class="field">LABEL<input value={pool.label} oninput={(event) => updatePool(index,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label>
|
||||
<SegmentedChoice legend="SCOPE" name={`${idPrefix}-pool-scope-${index}`} options={[{value:'agent',label:'AGENT'},{value:'target',label:'TARGET'}]} value={pool.scope} onchange={(values) => updatePool(index,{scope:values[0] as 'agent'|'target'})}/>
|
||||
<label class="field">INITIAL<input type="number" value={pool.initial} oninput={(event) => updatePool(index,{initial:Number(event.currentTarget.value)})}/></label>
|
||||
<label class="field">MAX<input type="number" min="1" value={pool.max ?? 1} oninput={(event) => updatePool(index,{max:Number(event.currentTarget.value)})}/></label>
|
||||
<label class="field">BEST CASE<input type="number" value={pool.bestCaseValue ?? pool.max ?? 1} oninput={(event) => updatePool(index,{bestCaseValue:Number(event.currentTarget.value)})}/></label>
|
||||
<label class="field">EXPIRY · SECONDS<input type="number" min="0.01" step="any" value={pool.expiresAfterSeconds ?? ''} oninput={(event) => updatePool(index,{expiresAfterSeconds:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
|
||||
<button type="button" class="remove" onclick={() => setRule({...rule,pools:rule.pools.filter((_,current) => current !== index)})}>REMOVE</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty-state">No state pools. Add one for stacks, timers, counters, resources, or target marks.</p>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<div class="branch-stack">
|
||||
{#each rule.clauses as clause, clauseIndex}
|
||||
<section class="branch">
|
||||
<header>
|
||||
<div><span>{String(clauseIndex + 1).padStart(2, '0')}</span><label class="field">BRANCH LABEL<input value={clause.label ?? ''} oninput={(event) => updateClause(clauseIndex,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label></div>
|
||||
<button type="button" class="remove" onclick={() => setRule({...rule,clauses:rule.clauses.filter((_,index) => index !== clauseIndex)})}>REMOVE BRANCH</button>
|
||||
</header>
|
||||
<div class="branch-grid">
|
||||
<div class="field"><span>DIRECT EVENT</span><SearchableAssignment items={eventItems} mode="single" compact subjectLabel="EVENT" bind:value={clause.event}/></div>
|
||||
<div class="branch-readout"><span>MATCH POLICY</span><strong>{reducerItems.find((item) => item.id === clause.reducer.kind)?.name ?? clause.reducer.kind}</strong></div>
|
||||
<label class="field">COOLDOWN · SECONDS<input type="number" min="0.01" step="any" value={clause.cooldownSeconds ?? ''} oninput={(event) => updateClause(clauseIndex,{cooldownSeconds:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
|
||||
</div>
|
||||
<div class="reducer-controls">
|
||||
<SegmentedChoice
|
||||
legend="MATCH POLICY"
|
||||
name={`${idPrefix}-reducer-${clauseIndex}`}
|
||||
options={reducerItems.map((item) => ({value:item.id,label:item.name.replace(' matches','').replace(' within a time window',' window').toUpperCase()}))}
|
||||
value={clause.reducer.kind}
|
||||
columns={4}
|
||||
onchange={(values) => reducerKind(clauseIndex,values[0])}
|
||||
/>
|
||||
{#if ['count','distinct_count','consecutive_count','sliding_count'].includes(clause.reducer.kind)}
|
||||
<label class="field">THRESHOLD<input type="number" min="1" step="1" value={'threshold' in clause.reducer ? clause.reducer.threshold : 1} oninput={(event) => setReducerNumber(clauseIndex,'threshold',event.currentTarget.value)}/></label>
|
||||
{/if}
|
||||
{#if ['distinct_count','sliding_count'].includes(clause.reducer.kind)}
|
||||
<label class="field">WINDOW · SECONDS<input type="number" min="0.01" step="any" value={'windowSeconds' in clause.reducer ? clause.reducer.windowSeconds : 1} oninput={(event) => setReducerNumber(clauseIndex,'windowSeconds',event.currentTarget.value)}/></label>
|
||||
{/if}
|
||||
{#if clause.reducer.kind === 'duration'}
|
||||
<label class="field">DURATION · SECONDS<input type="number" min="0.01" step="any" value={clause.reducer.seconds} oninput={(event) => setReducerNumber(clauseIndex,'seconds',event.currentTarget.value)}/></label>
|
||||
{:else if clause.reducer.kind === 'periodic'}
|
||||
<label class="field">INTERVAL · SECONDS<input type="number" min="0.01" step="any" value={typeof clause.reducer.intervalSeconds === 'number' ? clause.reducer.intervalSeconds : 1} oninput={(event) => setReducerNumber(clauseIndex,'intervalSeconds',event.currentTarget.value)}/></label>
|
||||
{:else if clause.reducer.kind === 'derived_scalar'}
|
||||
<label class="field">DERIVED VALUE<input type="number" step="any" value={typeof clause.reducer.value === 'number' ? clause.reducer.value : 1} oninput={(event) => setReducerNumber(clauseIndex,'value',event.currentTarget.value)}/></label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mutation-panel">
|
||||
<div class="outcome-heading"><div><span>STATE CHANGES</span><strong>{clause.mutations.length}</strong></div><button type="button" class="secondary compact-action" onclick={() => addMutation(clauseIndex)} disabled={!rule.pools.length}>ADD STATE CHANGE +</button></div>
|
||||
{#each clause.mutations as mutation, mutationIndex}
|
||||
<div class="mutation-row">
|
||||
<SegmentedChoice
|
||||
legend="OPERATION"
|
||||
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
|
||||
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'clear',label:'CLEAR'}]}
|
||||
value={mutation.kind === 'none' || mutation.kind === 'derive' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
|
||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'clear')}
|
||||
/>
|
||||
<SegmentedChoice
|
||||
legend="STATE POOL"
|
||||
name={`${idPrefix}-mutation-pool-${clauseIndex}-${mutationIndex}`}
|
||||
options={rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))}
|
||||
value={'pool' in mutation ? mutation.pool : rule.pools[0]?.key ?? ''}
|
||||
columns={Math.min(4,Math.max(1,rule.pools.length))}
|
||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'clear' ? 'clear' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])}
|
||||
/>
|
||||
{#if mutation.kind !== 'clear'}
|
||||
<label class="field">VALUE<input type="number" step="any" value={'value' in mutation && typeof mutation.value === 'number' ? mutation.value : 1} oninput={(event) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',undefined,Number(event.currentTarget.value))}/></label>
|
||||
{/if}
|
||||
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty-state">This branch emits outcomes without changing shared state.</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="outcome-heading"><div><span>OUTCOMES</span><strong>{clause.outcomes.length}</strong></div><button type="button" class="secondary compact-action" onclick={() => updateClause(clauseIndex,{outcomes:[...clause.outcomes,blankOutcome(clause.outcomes.length)]})}>ADD OUTCOME +</button></div>
|
||||
{#each clause.outcomes as outcome, outcomeIndex}
|
||||
<div class="outcome-card">
|
||||
<div class="outcome-grid">
|
||||
<label class="field">LABEL<input value={outcome.label} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label>
|
||||
<SegmentedChoice legend="KIND" name={`${idPrefix}-kind-${clauseIndex}-${outcomeIndex}`} options={outcomeKinds} value={outcome.kind} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{kind:values[0] as ConditionalMechanicOutcomeDraft['kind'],resource:values[0] === 'restoration' || values[0] === 'resource_cost' ? outcome.resource ?? 'health' : undefined,attributeId:values[0] === 'attribute_modifier' ? outcome.attributeId ?? '' : undefined})}/>
|
||||
<label class="field">DESCRIPTION SLOT<input type="number" min="1" step="1" value={outcome.descriptionSlot ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{descriptionSlot:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
|
||||
</div>
|
||||
<SegmentedChoice legend="TARGET SCOPE" name={`${idPrefix}-scope-${clauseIndex}-${outcomeIndex}`} options={scopeOptions} value={outcome.scope} columns={scopeOptions.length > 4 ? 4 : scopeOptions.length} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{scope:values[0] as MechanicOutcomeScope})}/>
|
||||
{#if outcome.kind === 'attribute_modifier'}
|
||||
<SearchableAssignment items={attributes} mode="single" compact subjectLabel="ATTRIBUTE" bind:value={outcome.attributeId}/>
|
||||
{:else if outcome.kind === 'restoration' || outcome.kind === 'resource_cost'}
|
||||
<SegmentedChoice legend="RESOURCE" name={`${idPrefix}-resource-${clauseIndex}-${outcomeIndex}`} options={[{value:'health',label:'HEALTH'},{value:'armor',label:'ARMOR'},{value:'ammo',label:'AMMO'}]} value={outcome.resource ?? 'health'} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{resource:values[0] as 'health'|'armor'|'ammo'})}/>
|
||||
{/if}
|
||||
{#if outcome.kind === 'direct_damage' || outcome.kind === 'restoration' || outcome.kind === 'resource_cost'}
|
||||
<SegmentedChoice legend="VALUE BASIS" name={`${idPrefix}-basis-${clauseIndex}-${outcomeIndex}`} options={[{value:'flat',label:'FLAT'},{value:'max_health_percent',label:'MAX HEALTH %'},{value:'current_health_percent',label:'CURRENT HEALTH %'},{value:'weapon_base_percent',label:'WEAPON BASE %'}]} value={outcome.basis} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{basis:values[0] as OutcomeBasis})}/>
|
||||
{/if}
|
||||
<SegmentedChoice
|
||||
legend="VALUE MODEL"
|
||||
name={`${idPrefix}-value-model-${clauseIndex}-${outcomeIndex}`}
|
||||
options={outcomeValueModel(outcome) === 'advanced' ? [...valueModelOptions,{value:'advanced',label:'ADVANCED',disabled:true}] : valueModelOptions}
|
||||
value={outcomeValueModel(outcome)}
|
||||
columns={3}
|
||||
onchange={(values) => setOutcomeValueModel(clauseIndex,outcomeIndex,values[0] as OutcomeValueModel,values[0] === 'event' ? 'values.enemyCount' : values[0] === 'scenario' ? rule.scenarioInputs[0]?.key ?? '' : '')}
|
||||
/>
|
||||
{#if outcomeValueModel(outcome) === 'event'}
|
||||
<SegmentedChoice
|
||||
legend="EVENT FIELD"
|
||||
name={`${idPrefix}-event-value-${clauseIndex}-${outcomeIndex}`}
|
||||
options={eventValueOptions}
|
||||
value={outcomeReference(outcome,'event')}
|
||||
columns={3}
|
||||
onchange={(values) => setOutcomeValueModel(clauseIndex,outcomeIndex,'event',values[0])}
|
||||
/>
|
||||
{:else if outcomeValueModel(outcome) === 'scenario' && rule.scenarioInputs.length}
|
||||
<SegmentedChoice
|
||||
legend="SCENARIO CONTROL"
|
||||
name={`${idPrefix}-scenario-value-${clauseIndex}-${outcomeIndex}`}
|
||||
options={rule.scenarioInputs.map((input) => ({value:input.key,label:input.label.toUpperCase()}))}
|
||||
value={outcomeReference(outcome,'scenario')}
|
||||
columns={Math.min(4,rule.scenarioInputs.length)}
|
||||
onchange={(values) => setOutcomeValueModel(clauseIndex,outcomeIndex,'scenario',values[0])}
|
||||
/>
|
||||
{/if}
|
||||
{#if rule.pools.length}
|
||||
<div class="state-scaling">
|
||||
<SegmentedChoice
|
||||
legend="STATE POOL"
|
||||
name={`${idPrefix}-outcome-pool-${clauseIndex}-${outcomeIndex}`}
|
||||
options={[{value:'',label:'NONE'},...rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))]}
|
||||
value={outcome.statePool ?? ''}
|
||||
columns={Math.min(4,rule.pools.length + 1)}
|
||||
onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{statePool:values[0] || undefined,perStack:values[0] ? outcome.perStack ?? true : false,minStacks:values[0] ? outcome.minStacks : undefined,maxStacks:values[0] ? outcome.maxStacks : undefined})}
|
||||
/>
|
||||
{#if outcome.statePool}
|
||||
<SegmentedChoice legend="SCALING" name={`${idPrefix}-outcome-scaling-${clauseIndex}-${outcomeIndex}`} options={[{value:'false',label:'FLAT'},{value:'true',label:'PER STACK'}]} value={String(outcome.perStack ?? true)} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{perStack:values[0] === 'true'})}/>
|
||||
<label class="field">MIN STACK<input type="number" min="0" step="1" value={outcome.minStacks ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{minStacks:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
|
||||
<label class="field">MAX STACK<input type="number" min="0" step="1" value={outcome.maxStacks ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{maxStacks:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="variant-grid">
|
||||
{#each valueColumns as column}
|
||||
<label class="field">{column.label} VALUE<input type="number" step="any" value={outcome.variants[column.variantKey]?.value ?? 0} oninput={(event) => updateVariant(clauseIndex,outcomeIndex,column.variantKey,event.currentTarget.value)}/></label>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="outcome-footer">
|
||||
<SegmentedChoice legend="PHASE" name={`${idPrefix}-phase-${clauseIndex}-${outcomeIndex}`} options={[{value:'on_event',label:'ON EVENT'},{value:'after_event',label:'AFTER EVENT'},{value:'while_active',label:'WHILE ACTIVE'},{value:'on_enter',label:'ON ENTER'},{value:'on_exit',label:'ON EXIT'}]} value={outcome.phase} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{phase:values[0] as OutcomePhase})}/>
|
||||
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{outcomes:clause.outcomes.filter((_,index) => index !== outcomeIndex)})}>REMOVE OUTCOME</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty-state">This branch has no outcomes yet.</p>
|
||||
{/each}
|
||||
</section>
|
||||
{/each}
|
||||
<button type="button" class="secondary add-branch" onclick={() => setRule({...rule,clauses:[...rule.clauses,blankClause()]})}>ADD DIRECT EVENT BRANCH +</button>
|
||||
</div>
|
||||
|
||||
<section class="script-preview">
|
||||
<div><span>GENERATED MECHANIC</span><strong>READ-ONLY PSEUDO-SCRIPT</strong><p>The server validates the same typed structure for every configured value variant.</p></div>
|
||||
<PseudoScript title="CURRENT MECHANIC PROGRAM" lines={scriptLines} readiness={readinessLabel}/>
|
||||
</section>
|
||||
</fieldset>
|
||||
|
||||
<style>
|
||||
.mechanic-editor{display:grid;gap:var(--shd-gap-sm);margin:0;padding:var(--shd-gap-sm);border:1px solid var(--shd-model-border);border-left:2px solid var(--shd-orange);background:var(--shd-model-surface)}
|
||||
.mechanic-editor>legend{padding:0 7px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.2px}
|
||||
.mechanic-heading,.recipe-panel>header,.scenario-panel>header,.state-panel>header,.branch>header,.outcome-heading{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm)}
|
||||
.mechanic-heading{padding:8px 10px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}
|
||||
.mechanic-heading>div,.recipe-panel>header>div,.scenario-panel>header>div,.state-panel>header>div,.outcome-heading>div{display:grid;gap:2px}
|
||||
.mechanic-heading span,.recipe-panel header span,.scenario-panel header span,.state-panel header span,.outcome-heading span{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.9px}
|
||||
.mechanic-heading p,.recipe-panel header small,.empty-state,.script-preview>div p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
|
||||
.recipe-panel,.scenario-panel,.state-panel,.branch{border:1px solid var(--shd-border);background:var(--shd-surface)}
|
||||
.recipe-panel>header,.scenario-panel>header,.state-panel>header,.branch>header,.outcome-heading{min-height:42px;padding:7px 9px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}
|
||||
.scenario-panel>p{margin:0;padding:8px 9px;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4;border-bottom:1px solid var(--shd-border-soft)}
|
||||
.scenario-row{display:grid;grid-template-columns:minmax(170px,1fr) minmax(260px,1.25fr) minmax(220px,1.3fr) repeat(4,minmax(85px,.45fr)) auto;align-items:end;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border-soft)}
|
||||
.scenario-row>.field{margin:0}.scenario-row>.description-field{min-width:0}
|
||||
.recipe-grid{display:grid;grid-template-columns:minmax(260px,1fr) repeat(3,minmax(110px,.35fr)) auto auto;align-items:end;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)}
|
||||
.recipe-grid>.field,.pool-row>.field,.branch-grid>.field,.outcome-grid>.field,.variant-grid>.field{margin:0}
|
||||
.replace-confirm{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm);padding:9px;border-top:1px solid var(--shd-danger);background:color-mix(in srgb,var(--shd-danger) 10%,var(--shd-surface))}
|
||||
.replace-confirm p{display:grid;gap:2px;margin:0}.replace-confirm span{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.replace-confirm>div{display:flex;gap:7px}
|
||||
.pool-row{display:grid;grid-template-columns:1.2fr .8fr repeat(4,.55fr) auto;align-items:end;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border-soft)}
|
||||
.branch-stack{display:grid;gap:var(--shd-gap-sm)}
|
||||
.branch>header>div{display:flex;align-items:end;gap:9px}.branch>header>div>span{align-self:center;color:var(--shd-orange);font:900 var(--shd-type-telemetry)/1 var(--shd-font-mono)}.branch>header .field{min-width:min(360px,55vw);margin:0}
|
||||
.branch-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr)) minmax(160px,.45fr);align-items:end;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)}
|
||||
.field>span{display:block;margin-bottom:5px;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
|
||||
.branch-readout{display:grid;align-content:center;gap:3px;min-height:44px;padding:7px 9px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}.branch-readout span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.branch-readout strong{font-size:var(--shd-type-supporting)}
|
||||
.reducer-controls{display:grid;grid-template-columns:minmax(0,1fr) repeat(2,minmax(130px,.25fr));align-items:end;gap:var(--shd-gap-sm);padding:0 var(--shd-gap-sm) var(--shd-gap-sm)}
|
||||
.mutation-panel{margin:0 var(--shd-gap-sm) var(--shd-gap-sm);border:1px solid var(--shd-border-soft)}
|
||||
.mutation-row{display:grid;grid-template-columns:minmax(300px,1fr) minmax(260px,1fr) minmax(110px,.35fr) auto;align-items:end;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border-soft)}
|
||||
.outcome-card{display:grid;gap:8px;margin:var(--shd-gap-sm);padding:9px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}
|
||||
.outcome-grid{display:grid;grid-template-columns:minmax(200px,1fr) minmax(360px,1.4fr) minmax(120px,.3fr);align-items:end;gap:8px}
|
||||
.variant-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px}
|
||||
.state-scaling{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr) repeat(2,minmax(110px,.3fr));align-items:end;gap:8px}
|
||||
.outcome-footer{display:flex;align-items:end;justify-content:space-between;gap:8px}
|
||||
.remove,.danger-quiet{min-height:38px;padding:0 10px;border:1px solid var(--shd-danger);background:transparent;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}
|
||||
.remove:hover,.remove:focus-visible,.danger-quiet:hover,.danger-quiet:focus-visible{background:var(--shd-danger);color:var(--shd-surface)}
|
||||
.compact-action{min-height:36px}.add-branch{min-height:44px}.empty-state{padding:10px}.script-preview{display:grid;grid-template-columns:minmax(190px,.28fr) minmax(0,1fr);gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-border);border-top:2px solid var(--shd-success);background:var(--shd-surface)}
|
||||
.script-preview>div{display:grid;align-content:start;gap:3px}.script-preview>div span{color:var(--shd-success);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
|
||||
@media(max-width:1300px){.scenario-row{grid-template-columns:repeat(2,minmax(0,1fr))}.scenario-row>.description-field{grid-column:1/-1}}
|
||||
@media(max-width:1000px){.recipe-grid,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.script-preview{grid-template-columns:1fr 1fr}.recipe-grid>:first-child,.script-preview>:last-child{grid-column:1/-1}.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:1/-1}}
|
||||
@media(max-width:620px){.mechanic-heading,.recipe-panel>header,.scenario-panel>header,.state-panel>header,.branch>header,.outcome-heading,.replace-confirm,.outcome-footer{align-items:stretch;flex-direction:column}.recipe-grid,.scenario-row,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.script-preview{grid-template-columns:1fr}.recipe-grid>:first-child,.script-preview>:last-child,.scenario-row>.description-field,.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:auto}.branch>header>div{align-items:stretch;flex-direction:column}.branch>header .field{min-width:0}.remove,.danger-quiet,.compact-action,.add-branch{width:100%;min-height:44px}}
|
||||
@media(prefers-reduced-motion:reduce){.remove,.danger-quiet{transition:none}}
|
||||
</style>
|
||||
Reference in New Issue
Block a user