Support target-scoped trauma mechanics
This commit is contained in:
@@ -88,7 +88,7 @@
|
||||
];
|
||||
|
||||
const mutations = [
|
||||
{ syntax: 'ADD value TO pool', use: 'Increases state, respecting the pool maximum.' },
|
||||
{ syntax: 'ADD value TO pool FOR EVENT TARGET', use: 'Increases one enemy’s target-scoped state, respecting the pool maximum.' },
|
||||
{ syntax: 'SET value AS pool', use: 'Replaces the current pool value.' },
|
||||
{ syntax: 'CONSUME value FROM pool', use: 'Spends available state without falling below the pool minimum.' },
|
||||
{ syntax: 'CLEAR pool', use: 'Resets the selected state pool.' },
|
||||
@@ -99,7 +99,7 @@
|
||||
{ syntax: 'ON_EVENT', use: 'Applies against combat state as the event arrives.' },
|
||||
{ syntax: 'AFTER_EVENT', use: 'Applies after branch mutations and resource costs.' },
|
||||
{ syntax: 'ON_ENTER', use: 'Applies when tracked state enters an active range.' },
|
||||
{ syntax: 'ON_EXIT', use: 'Applies when tracked state leaves an active range.' },
|
||||
{ syntax: 'ON_EXIT', use: 'Applies when tracked state leaves an active range, using its final pre-exit value for stack thresholds and scaling.' },
|
||||
{ syntax: 'WHILE_ACTIVE', use: 'Remains active while the referenced state satisfies its stack range.' }
|
||||
];
|
||||
|
||||
@@ -155,6 +155,22 @@
|
||||
'ON_EVENT: APPLY 2 CURRENT_HEALTH_PERCENT // Health cost',
|
||||
'AFTER_EVENT: APPLY (0.6 * (1 + (EQUIPMENT.protocolTalentUpgrades / 5))) CURRENT_HEALTH_PERCENT USING CURRENT HEALTH // Additional damage'
|
||||
];
|
||||
|
||||
const preciseTrauma = [
|
||||
'MECHANIC PRECISE TRAUMA',
|
||||
'STATE trauma [TARGET] 0..10; EXPIRES 1s AFTER WRITE',
|
||||
'',
|
||||
'WHEN WEAPON HIT // Body shot',
|
||||
'IF EVENT.targetId EXISTS AND EVENT.values.headshot == false',
|
||||
'ADD 1 TO trauma FOR EVENT TARGET',
|
||||
'',
|
||||
'WHEN WEAPON HIT // Headshot',
|
||||
'IF EVENT.targetId EXISTS AND EVENT.values.headshot == true',
|
||||
'ADD 2 TO trauma FOR EVENT TARGET',
|
||||
'',
|
||||
'WHEN SIMULATION TICK // Shared refreshed expiry',
|
||||
'ON_EXIT: APPLY (1 * ATTRIBUTE[skill-damage-id]) FLAT DIRECT_DAMAGE PER STACK TO EVENT TARGET'
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -210,6 +226,13 @@
|
||||
<article>
|
||||
<div><strong>{event.label}</strong><code>{event.key}</code></div>
|
||||
<p>{event.description}</p>
|
||||
{#if event.fields?.length}
|
||||
<ul aria-label={`${event.label} typed fields`}>
|
||||
{#each event.fields as field}
|
||||
<li><code>{field.path}</code><span>{field.label} · {field.type.toUpperCase()}</span></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -318,7 +341,7 @@
|
||||
</div>
|
||||
<aside class="contract">
|
||||
<strong>EXPIRY CONTRACT</strong>
|
||||
<span>A pool can expire after its last write. A new stack refreshes that timer when the mechanic defines refresh-on-write behavior.</span>
|
||||
<span>A pool can expire after its last write. Each write refreshes its timer. Target pools maintain independent values and timers for each bound enemy.</span>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
@@ -376,6 +399,10 @@
|
||||
<section id="examples" class="reference-section examples-section">
|
||||
<header><div><span>10</span><h3>WORKED MECHANICS</h3></div></header>
|
||||
<div class="example-intro">
|
||||
<div>
|
||||
<h4>PRECISE TRAUMA</h4>
|
||||
<p>Body shots add one target-bound stack and headshots add two. Each hit refreshes that enemy’s shared one-second expiry; the expiry deals one live Skill Damage value per final stack.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4>SUPERIOR FIREPOWER</h4>
|
||||
<p>Nine hits on one target add a Relentless stack. Each write refreshes the ten-second pool timer.</p>
|
||||
@@ -386,6 +413,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="example-scripts">
|
||||
<PseudoScript title="PRECISE TRAUMA · GENERATED EXAMPLE" lines={preciseTrauma} readiness="READY" />
|
||||
<PseudoScript title="SUPERIOR FIREPOWER · GENERATED EXAMPLE" lines={superiorFirepower} readiness="READY" />
|
||||
<PseudoScript title="HAPPY SURVIVAL · GENERATED EXAMPLE" lines={happySurvival} readiness="REQUIRES ASSUMPTIONS" />
|
||||
</div>
|
||||
@@ -652,6 +680,25 @@
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.event-registry ul {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 2px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.event-registry li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding-top: 5px;
|
||||
border-top: 1px solid var(--shd-border-soft);
|
||||
color: var(--shd-text-muted);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
}
|
||||
|
||||
.action-registry {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -937,7 +984,7 @@
|
||||
|
||||
.example-scripts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
@@ -219,28 +219,33 @@ export const actions: Actions = {
|
||||
valueII: effect.variants.II.value
|
||||
}));
|
||||
const validationErrors: TalentValidationError[] = [];
|
||||
const requiresCompleteMechanic = conditionalRule.simulationReadiness !== 'captured_only';
|
||||
if (name.length < 2 || !['Weapon Talent 1', 'Weapon Talent 2', 'Backpack', 'Body Armor'].includes(category)) {
|
||||
validationErrors.push({ area: 'identity', message: 'Provide a valid talent name and category. Exotic talents are managed from their owning weapon.' });
|
||||
}
|
||||
if (!descriptionTemplate) validationErrors.push({ area: 'description', message: 'Enter a shared description template.' });
|
||||
if (!conditionalRule.clauses.length) validationErrors.push({ area: 'mechanic', message: 'Add at least one mechanic branch.' });
|
||||
if (!sharedEffects.length) validationErrors.push({ area: 'outcomes', message: 'Add at least one numbered value outcome.' });
|
||||
if (requiresCompleteMechanic && !conditionalRule.clauses.length) {
|
||||
validationErrors.push({ area: 'mechanic', message: 'Add at least one mechanic branch or mark the talent CAPTURE ONLY.' });
|
||||
}
|
||||
if (requiresCompleteMechanic && !sharedEffects.length) {
|
||||
validationErrors.push({ area: 'outcomes', message: 'Add at least one numbered value outcome or mark the talent CAPTURE ONLY.' });
|
||||
}
|
||||
if (sharedEffects.some((effect) => !Number.isFinite(effect.valueI) || !Number.isFinite(effect.valueII))) {
|
||||
validationErrors.push({ area: 'outcomes', message: 'Every numbered outcome needs valid Tier I and Tier II values.' });
|
||||
}
|
||||
const missingSlots = missingTalentDescriptionSlots(descriptionTemplate, descriptionOutcomes);
|
||||
if (missingSlots.length) {
|
||||
if (requiresCompleteMechanic && missingSlots.length) {
|
||||
validationErrors.push({
|
||||
area: 'description',
|
||||
message: `Description placeholder${missingSlots.length === 1 ? '' : 's'} ${missingSlots.map((slot) => `{${slot}}`).join(', ')} must reference numbered outcomes.`
|
||||
});
|
||||
validationErrors.push({ area: 'outcomes', message: 'Add or renumber outcomes to match every numbered description placeholder.' });
|
||||
}
|
||||
if (descriptionTemplate.includes('{duration}') && durationSeconds === null) {
|
||||
if (requiresCompleteMechanic && descriptionTemplate.includes('{duration}') && durationSeconds === null) {
|
||||
validationErrors.push({ area: 'description', message: 'The description uses {duration}, but no duration is defined.' });
|
||||
validationErrors.push({ area: 'mechanic', message: 'Define a duration for the {duration} description placeholder.' });
|
||||
}
|
||||
if (descriptionTemplate.includes('{max_stacks}') && maxStacks === null) {
|
||||
if (requiresCompleteMechanic && descriptionTemplate.includes('{max_stacks}') && maxStacks === null) {
|
||||
validationErrors.push({ area: 'description', message: 'The description uses {max_stacks}, but no maximum stack count is defined.' });
|
||||
validationErrors.push({ area: 'mechanic', message: 'Define a pool maximum for the {max_stacks} description placeholder.' });
|
||||
}
|
||||
|
||||
@@ -42,28 +42,9 @@
|
||||
simulationReadiness: 'captured_only',
|
||||
scenarioInputs: [],
|
||||
pools: [],
|
||||
clauses: [{
|
||||
key: 'primary',
|
||||
label: 'Primary',
|
||||
event: 'weapon_hit',
|
||||
if: { kind: 'true' },
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [{
|
||||
key: 'effect-1',
|
||||
label: 'Effect 1',
|
||||
phase: 'while_active',
|
||||
kind: 'attribute_modifier',
|
||||
scope: 'equipped_weapon',
|
||||
attributeId: '',
|
||||
basis: 'flat',
|
||||
perStack: false,
|
||||
descriptionSlot: 1,
|
||||
variants: { I: { value: 0 }, II: { value: 0 } }
|
||||
}]
|
||||
}]
|
||||
clauses: []
|
||||
});
|
||||
const blankTalent = () => ({ id: '', name: '', iconUrl: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', conditionalRule: blankRule() });
|
||||
const blankTalent = () => ({ id: '', name: '', iconUrl: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', conditionalRule: blankRule(), modelingOpen: false });
|
||||
const editTalent = (row: any) => {
|
||||
const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', effects: [] };
|
||||
const one = tier('I'), two = tier('II');
|
||||
@@ -72,7 +53,8 @@
|
||||
descriptionTemplate: row.descriptionTemplate || one.description,
|
||||
tierIDescription: one.description,
|
||||
tierIIDescription: two.description,
|
||||
conditionalRule: row.conditionalRule ?? blankRule()
|
||||
conditionalRule: row.conditionalRule ?? blankRule(),
|
||||
modelingOpen: Boolean(row.conditionalRule?.clauses?.length)
|
||||
};
|
||||
};
|
||||
const configured = (tiers: Array<{ tier: unknown }>, name: string) => tiers.some((item) => item.tier === name);
|
||||
@@ -105,6 +87,7 @@
|
||||
if (form.talentMode === 'add') iconResetToken += 1;
|
||||
talent = form.talentMode === 'add' ? blankTalent() : null;
|
||||
} else if (talentErrors.length) {
|
||||
if (hasErrors(['mechanic', 'outcomes'])) talent.modelingOpen = true;
|
||||
void tick().then(() => validationSummary?.focus());
|
||||
}
|
||||
});
|
||||
@@ -143,20 +126,26 @@
|
||||
</div>
|
||||
<label id="talent-description" class="field template-field" class:validation-area-error={hasErrors(['description'])}>SHARED DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={talent.descriptionTemplate} placeholder={'Hitting an enemy {1} times grants +{2} for {duration}...'} required aria-invalid={hasErrors(['description']) ? 'true' : undefined}></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values. Use {'{duration}'} and {'{max_stacks}'} for defined mechanics. Cooldown is displayed separately.</small>{#each errorsFor('description') as error}<small class="field-error">{error.message}</small>{/each}</label>
|
||||
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{preview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{preview('II') || 'Description preview'}</p></div></div>
|
||||
<div id="talent-mechanic" class:validation-area-error={hasErrors(['mechanic', 'outcomes'])}>
|
||||
<MechanicProgramEditor
|
||||
attributes={data.stats}
|
||||
valueColumns={[{key:'tierI',variantKey:'I',label:'TIER I'},{key:'tierII',variantKey:'II',label:'TIER II'}]}
|
||||
idPrefix={`talent-${talent.id || 'new'}`}
|
||||
aiContext={{
|
||||
sourceKind: 'talent',
|
||||
sourceName: talent?.name ?? '',
|
||||
sourceDescription: talent?.descriptionTemplate ?? ''
|
||||
}}
|
||||
bind:rule={talent.conditionalRule}
|
||||
/>
|
||||
{#each [...errorsFor('mechanic'), ...errorsFor('outcomes')] as error}<small class="field-error mechanic-field-error">{error.message}</small>{/each}
|
||||
</div>
|
||||
<details id="talent-mechanic" class="modeling-disclosure" class:validation-area-error={hasErrors(['mechanic', 'outcomes'])} bind:open={talent.modelingOpen}>
|
||||
<summary>
|
||||
<span><b>COMBAT MODELING</b><small>{talent.conditionalRule.simulationReadiness === 'captured_only' ? 'CAPTURE ONLY · MODEL LATER' : `${talent.conditionalRule.clauses.length} CONFIGURED ${talent.conditionalRule.clauses.length === 1 ? 'BRANCH' : 'BRANCHES'}`}</small></span>
|
||||
<strong>{talent.modelingOpen ? 'COLLAPSE' : 'EXPAND'}</strong>
|
||||
</summary>
|
||||
<div class="modeling-content">
|
||||
<MechanicProgramEditor
|
||||
attributes={data.stats}
|
||||
valueColumns={[{key:'tierI',variantKey:'I',label:'TIER I'},{key:'tierII',variantKey:'II',label:'TIER II'}]}
|
||||
idPrefix={`talent-${talent.id || 'new'}`}
|
||||
aiContext={{
|
||||
sourceKind: 'talent',
|
||||
sourceName: talent?.name ?? '',
|
||||
sourceDescription: talent?.descriptionTemplate ?? ''
|
||||
}}
|
||||
bind:rule={talent.conditionalRule}
|
||||
/>
|
||||
{#each [...errorsFor('mechanic'), ...errorsFor('outcomes')] as error}<small class="field-error mechanic-field-error">{error.message}</small>{/each}
|
||||
</div>
|
||||
</details>
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => talent = null}>CANCEL</button><button class="primary">SAVE TALENT →</button></div>
|
||||
</PendingForm>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user