Support target-scoped trauma mechanics

This commit is contained in:
JB
2026-07-27 22:20:52 -04:00
parent 7e78d75f85
commit 0b3be3587b
20 changed files with 1032 additions and 93 deletions
@@ -40,10 +40,11 @@
<div><dt>IF</dt><dd>Conditions checked against the event, Agent, target, team, or encounter.</dd></div>
<div><dt>TRACK</dt><dd>A count, duration, scalar, mark, or stack pool shared by branches.</dd></div>
<div><dt>ADD / SET / CONSUME</dt><dd>How a matching branch changes tracked state.</dd></div>
<div><dt>FOR EVENT TARGET</dt><dd>Binds a target-scoped state change to the enemy named by the event.</dd></div>
<div><dt>APPLY</dt><dd>The Attribute or direct result and the phase when it takes effect.</dd></div>
<div><dt>UNTIL</dt><dd>The expiry, reset, or transfer policy for tracked state.</dd></div>
<div><dt>EVERY</dt><dd>An explicit periodic interval. ISAC does not infer a missing cadence.</dd></div>
<div><dt>PHASE</dt><dd>Whether an outcome runs before state changes, after them, or while state remains active.</dd></div>
<div><dt>PHASE</dt><dd>Whether an outcome runs before state changes, after them, while state remains active, or when state expires.</dd></div>
</dl>
<PseudoScript title="MECHANIC EXAMPLE" lines={example} compact />
<section class="calculation-language">
@@ -235,6 +235,7 @@
| 'static'
| 'event'
| 'attribute'
| 'attribute_value'
| 'reducer'
| 'scenario'
| 'protocol_upgrades'
@@ -243,6 +244,7 @@
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' },
@@ -298,6 +300,14 @@
};
}
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;
@@ -313,6 +323,7 @@
if (expression.right.ref.source === 'event') return 'event';
if (expression.right.ref.source === 'scenario') return 'scenario';
if (expression.right.ref.source === 'reducer') return 'reducer';
if (expression.right.ref.source === 'attribute') return 'attribute_value';
}
if (
'op' in expression.right
@@ -356,6 +367,15 @@
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'
@@ -380,6 +400,9 @@
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;
@@ -398,7 +421,9 @@
) {
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
const nextOverrides = { ...valueModelOverrides };
if (model === 'attribute' && !reference) nextOverrides[outcome.key] = 'attribute';
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]) => {
@@ -442,6 +467,17 @@
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' } } };
}
@@ -452,8 +488,11 @@
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 }]
mutations: [...rule.clauses[clauseIndex].mutations, { kind: 'add', pool, value: 1, entity }]
});
}
@@ -461,9 +500,25 @@
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 }
: { kind, pool: nextPool, value: value ?? ('value' in current && typeof current.value === 'number' ? current.value : 1) };
? { kind, pool: nextPool, entity }
: { kind, pool: nextPool, value: value ?? ('value' in current && typeof current.value === 'number' ? current.value : 1), entity };
updateClause(clauseIndex, { mutations });
}
function updateMutationEntity(
clauseIndex: number,
mutationIndex: number,
kind: 'event_target' | 'event_actor'
) {
const mutations = [...rule.clauses[clauseIndex].mutations];
const mutation = mutations[mutationIndex];
if (!('pool' in mutation) || mutation.kind === 'transfer') return;
mutations[mutationIndex] = { ...mutation, entity: { kind } };
updateClause(clauseIndex, { mutations });
}
@@ -680,6 +735,15 @@
{#if mutation.kind !== 'clear'}
<label class="field">VALUE<input type="number" step="any" value={'value' in mutation && typeof mutation.value === 'number' ? mutation.value : 1} oninput={(event) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',undefined,Number(event.currentTarget.value))}/></label>
{/if}
{#if 'pool' in mutation && rule.pools.find((pool) => pool.key === mutation.pool)?.scope === 'target'}
<SegmentedChoice
legend="STATE OWNER"
name={`${idPrefix}-mutation-entity-${clauseIndex}-${mutationIndex}`}
options={[{value:'event_target',label:'EVENT TARGET'},{value:'event_actor',label:'EVENT ACTOR'}]}
value={'entity' in mutation && mutation.entity?.kind === 'event_actor' ? 'event_actor' : 'event_target'}
onchange={(values) => updateMutationEntity(clauseIndex,mutationIndex,values[0] as 'event_target'|'event_actor')}
/>
{/if}
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
</div>
{:else}
@@ -747,14 +811,14 @@
columns={Math.min(4,rule.scenarioInputs.length)}
onchange={(values) => setOutcomeValueModel(clauseIndex,outcomeIndex,'scenario',values[0])}
/>
{:else if outcomeValueModel(outcome) === 'attribute'}
{: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,'attribute',value)}
onvaluechange={(value) => setOutcomeValueModel(clauseIndex,outcomeIndex,outcomeValueModel(outcome),value)}
/>
{/if}
{#if rule.pools.length}
@@ -823,7 +887,7 @@
.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)}
.mutation-row{display:grid;grid-template-columns:minmax(260px,1fr) minmax(220px,1fr) minmax(110px,.35fr) minmax(240px,1fr) auto;align-items:end;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border-soft)}
.outcome-card{display:grid;gap:8px;margin:var(--shd-gap-sm);padding:9px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}
.outcome-grid{display:grid;grid-template-columns:minmax(200px,1fr) minmax(360px,1.4fr) minmax(120px,.3fr);align-items:end;gap:8px}
.variant-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px}