Support live attribute restoration mechanics

This commit is contained in:
JB
2026-07-27 21:32:04 -04:00
parent 36dd018a4a
commit 568e46cd05
19 changed files with 979 additions and 31 deletions
@@ -12,7 +12,8 @@
subjectLabel = 'TALENTS',
searchLabel = `FILTER AVAILABLE ${subjectLabel}`,
searchPlaceholder = `Search ${subjectLabel.toLocaleLowerCase()}…`,
emptySelectionLabel = `NO ${subjectLabel} ASSIGNED`
emptySelectionLabel = `NO ${subjectLabel} ASSIGNED`,
onvaluechange
}: {
items: Item[];
selected?: string[];
@@ -26,6 +27,7 @@
searchLabel?: string;
searchPlaceholder?: string;
emptySelectionLabel?: string;
onvaluechange?: (value: string) => void;
} = $props();
let query = $state('');
let open = $state(false);
@@ -42,6 +44,7 @@
const toggle = (id: string) => {
if (mode === 'single') {
value = value === id ? '' : id;
onvaluechange?.(value);
if (compact) {
open = false;
query = '';
@@ -60,6 +60,7 @@
let recipeWindow = $state(3);
let recipeDistance = $state(10);
let replaceConfirmation = $state(false);
let valueModelOverrides = $state<Record<string, OutcomeValueModel>>({});
const readinessOptions = [
{ value: 'ready', label: 'READY' },
@@ -173,7 +174,7 @@
kind: 'attribute_modifier',
scope: 'self',
basis: 'flat',
perStack: true,
perStack: false,
descriptionSlot: index + 1,
attributeId: '',
variants: Object.fromEntries(valueColumns.map((column) => [
@@ -233,6 +234,7 @@
type OutcomeValueModel =
| 'static'
| 'event'
| 'attribute'
| 'reducer'
| 'scenario'
| 'protocol_upgrades'
@@ -241,6 +243,7 @@
const valueModelOptions = $derived([
{ value: 'static', label: 'STATIC' },
{ value: 'event', label: 'EVENT FIELD' },
{ value: 'attribute', label: 'ATTRIBUTE ÷ 100' },
{ value: 'reducer', label: 'REDUCER' },
{ value: 'scenario', label: 'SCENARIO' },
...(allowProtocolUpgradeScaling
@@ -283,7 +286,20 @@
};
}
function attributePercentExpression(value: number, attributeId: string): NumericExpression {
return {
op: 'multiply',
left: value,
right: {
op: 'divide',
left: { ref: { source: 'attribute', attributeId } },
right: 100
}
};
}
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 (
@@ -298,6 +314,14 @@
if (expression.right.ref.source === 'scenario') return 'scenario';
if (expression.right.ref.source === 'reducer') return 'reducer';
}
if (
'op' in expression.right
&& expression.right.op === 'divide'
&& typeof expression.right.left === 'object'
&& 'ref' in expression.right.left
&& expression.right.left.ref.source === 'attribute'
&& expression.right.right === 100
) return 'attribute';
if (
'op' in expression.right
&& expression.right.op === 'add'
@@ -330,12 +354,32 @@
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'
&& 'op' in expression.right
&& expression.right.op === 'divide'
&& typeof expression.right.left === 'object'
&& 'ref' in expression.right.left
&& expression.right.left.ref.source === 'attribute'
) return expression.right.left.ref.attributeId;
return '';
}
function expressionForModel(
model: OutcomeValueModel,
value: number,
reference = ''
): NumericExpression | undefined {
if (model === 'event') return referenceExpression('event', reference || 'values.enemyCount', value);
if (model === 'attribute') return reference
? attributePercentExpression(value, reference)
: undefined;
if (model === 'scenario') return reference
? referenceExpression('scenario', reference, value)
: undefined;
@@ -353,6 +397,10 @@
reference = ''
) {
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
const nextOverrides = { ...valueModelOverrides };
if (model === 'attribute' && !reference) nextOverrides[outcome.key] = 'attribute';
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) } : {
@@ -383,6 +431,17 @@
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 === 'reducer') {
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
}
@@ -633,7 +692,7 @@
<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})}/>
<SegmentedChoice legend="KIND" name={`${idPrefix}-kind-${clauseIndex}-${outcomeIndex}`} options={outcomeKinds} value={outcome.kind} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{kind:values[0] as ConditionalMechanicOutcomeDraft['kind'],resource:values[0] === 'restoration' || values[0] === 'resource_cost' ? outcome.resource ?? 'health' : undefined,attributeId:values[0] === 'attribute_modifier' ? outcome.attributeId ?? '' : undefined,delivery:values[0] === 'restoration' ? outcome.delivery : undefined,durationSeconds:values[0] === 'restoration' ? outcome.durationSeconds : undefined})}/>
<label class="field">DESCRIPTION SLOT<input type="number" min="1" step="1" value={outcome.descriptionSlot ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{descriptionSlot:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
</div>
<SegmentedChoice legend="TARGET SCOPE" name={`${idPrefix}-scope-${clauseIndex}-${outcomeIndex}`} options={scopeOptions} value={outcome.scope} columns={scopeOptions.length > 4 ? 4 : scopeOptions.length} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{scope:values[0] as MechanicOutcomeScope})}/>
@@ -642,6 +701,23 @@
{: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 === '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}
@@ -671,6 +747,15 @@
columns={Math.min(4,rule.scenarioInputs.length)}
onchange={(values) => setOutcomeValueModel(clauseIndex,outcomeIndex,'scenario',values[0])}
/>
{:else if outcomeValueModel(outcome) === 'attribute'}
<SearchableAssignment
items={attributes}
mode="single"
compact
subjectLabel="VALUE ATTRIBUTE"
value={outcomeAttributeReference(outcome)}
onvaluechange={(value) => setOutcomeValueModel(clauseIndex,outcomeIndex,'attribute',value)}
/>
{/if}
{#if rule.pools.length}
<div class="state-scaling">
@@ -743,13 +828,14 @@
.outcome-grid{display:grid;grid-template-columns:minmax(200px,1fr) minmax(360px,1.4fr) minmax(120px,.3fr);align-items:end;gap:8px}
.variant-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px}
.state-scaling{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr) repeat(2,minmax(110px,.3fr));align-items:end;gap:8px}
.delivery-controls{display:grid;grid-template-columns:minmax(260px,1fr) minmax(150px,.35fr);align-items:end;gap:8px}.delivery-controls>.field{margin:0}
.outcome-footer{display:flex;align-items:end;justify-content:space-between;gap:8px}
.remove,.danger-quiet{min-height:38px;padding:0 10px;border:1px solid var(--shd-danger);background:transparent;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}
.remove:hover,.remove:focus-visible,.danger-quiet:hover,.danger-quiet:focus-visible{background:var(--shd-danger);color:var(--shd-surface)}
.compact-action{min-height:36px}.add-branch{min-height:44px}.empty-state{padding:10px}.script-preview{display:grid;grid-template-columns:minmax(190px,.28fr) minmax(0,1fr);gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-border);border-top:2px solid var(--shd-success);background:var(--shd-surface)}
.script-preview>div{display:grid;align-content:start;gap:3px}.script-preview>div span{color:var(--shd-success);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
@media(max-width:1300px){.scenario-row{grid-template-columns:repeat(2,minmax(0,1fr))}.scenario-row>.description-field{grid-column:1/-1}}
@media(max-width:1000px){.recipe-grid,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.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(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}}
@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{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>