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
+21 -8
View File
@@ -2005,20 +2005,33 @@ branches, ordered outcomes, and the generated read-only script. Use connected
panels and restrained borders to preserve the hierarchy; do not present the
typed graph as an undifferentiated form.
Talent editors place that surface inside the shared `modeling-disclosure`
pattern. New `CAPTURED ONLY` Talents start with an empty program and a collapsed
`COMBAT MODELING` summary so authors can record identity, image, and source
description first. Existing modeled Talents open the disclosure when edited.
Mechanic validation errors open it and keep the relevant error text visible.
The summary reports `CAPTURE ONLY · MODEL LATER` or the configured branch count
in text. Its native disclosure semantics preserve keyboard and assistive
technology support; mobile targets remain at least 44 pixels tall.
The direct-event picker reads the 19 normalized events from the code-defined
event registry. Use searchable single-assignment because the vocabulary exceeds
the compact segmented-choice limit. Each branch presents its event, match
policy and parameters, optional cooldown, state mutations, and outcomes.
Recipe-generated predicates and weights remain visible in the generated
script. State rows expose label, agent or target scope, initial value, bounds,
best-case value, and optional expiry. Outcome rows expose kind, target scope,
Attribute or resource when applicable, value basis, state scaling, phase, and
every source-specific value column. The Value Model control supports static
values, live Attribute percentages, event fields, reducer output, source-defined
scenario controls, and OS Protocol upgrade scaling where the source permits it.
Selecting a live Attribute uses the shared searchable single-assignment pattern
and displays the Attribute's unit. Label the percentage-normalized model
`ATTRIBUTE ÷ 100` so authors can see the transform applied to the stored value.
best-case value, and optional expiry. A state change that writes a target pool
uses a `STATE OWNER` segmented control with `EVENT TARGET` and `EVENT ACTOR`;
the selected owner remains visible in the generated script. Outcome rows expose
kind, target scope, Attribute or resource when applicable, value basis, state
scaling, phase, and every source-specific value column. The Value Model control
supports static values, live Attribute totals, percentage-normalized Attribute
values, event fields, reducer output, source-defined scenario controls, and OS
Protocol upgrade scaling where the source permits it. Selecting either
Attribute model uses the shared searchable single-assignment pattern and
displays the Attribute's unit. Label the direct model `LIVE ATTRIBUTE`. Label
the percentage-normalized model `ATTRIBUTE ÷ 100` so authors can see the
transform applied to the stored value.
Restoration outcomes also expose an
`INSTANT` or `CONTINUOUS` segmented choice. Continuous delivery reveals one
required duration field and describes the configured value as the total
+21 -3
View File
@@ -398,7 +398,10 @@ normalized events are `shot_fired`, `weapon_hit`, `skill_deployed`,
`movement_snapshot`, `proximity_snapshot`, `firing_started`, `firing_stopped`,
`reload_started`, `reload_completed`, `weapon_swapped`, and `tick`. Event
details such as target, distance, critical state, source, movement, and nearby
counts travel as typed event values.
counts travel as typed event values. The event registry publishes authoritative
field metadata where branches need a bounded contract. For example,
`weapon_hit.values.headshot` is a boolean that distinguishes headshots from
body shots.
The optional Action Family catalog provides authoring recipes for common
mechanics. Applying a recipe copies its typed scenario inputs, state pools, and
@@ -408,6 +411,12 @@ dependency. Adding another recipe appends branches. Replacing a program with a
recipe requires confirmation because replacement discards the current branch
graph.
A Talent may save an empty `captured_only` program while an author records its
name, category, image, and source description. The registry marks that Talent
`CONFIG REQUIRED`, and calculations exclude it. Changing readiness to `ready`
or `requires_assumptions` restores strict branch, numbered-outcome, and
description-placeholder validation.
The program graph uses three child tables:
- `conditional_effect_state_pools` defines agent- or target-scoped flags,
@@ -418,14 +427,16 @@ The program graph uses three child tables:
state mutations, and optional recipe provenance. Reducers convert event
streams into counts, distinct or consecutive matches, sliding windows,
durations, intervals, or derived values. Mutations can set, add, consume,
clear, derive, or transfer state.
clear, derive, or transfer state. A mutation may bind a target-scoped pool to
the current event target, event actor, or another typed entity selector. Each
target owns an independent value and expiry timer.
- `conditional_effect_outcomes` applies Attribute modifiers, direct damage,
restoration, resource costs, or markers. Each outcome names its target scope,
value basis, optional resource or Attribute, combat timing, optional state
band, and phase: `on_event`, `after_event`, `on_enter`, `on_exit`, or
`while_active`. One typed value or expression exists for every variant
required by the owning source. The common value models are static values,
live Attribute percentages, event-field values, reducer output,
live Attribute values, percentage-normalized Attribute values, event-field values, reducer output,
source-defined scenario values, and OS Protocol upgrade scaling. Restoration
outcomes may deliver their total immediately or continuously over an explicit
duration.
@@ -436,6 +447,13 @@ convert event streams into activations or derived values. The runtime applies
state mutations, enforces pool bounds and expiry, emits outcomes in phase order,
and records telemetry for each activation.
Each write to an expiring pool refreshes its timer. When a pool expires,
`on_exit` outcomes evaluate stack thresholds and per-stack expressions against
the final value before reset. The runtime records the bound target on emitted
outcomes. A one-second target debuff with a one-second damage cadence can
therefore resolve once at its refreshed shared expiry without inventing
intermediate ticks.
Live Attribute references use a stable Attribute ID and read the authoritative
loadout total that the builder captures for the evaluation pass. Percentage
Attributes retain their displayed units, so an expression that applies a
+16
View File
@@ -2393,3 +2393,19 @@
.field-error{display:block;margin:5px 0 0;color:var(--shd-danger);font-size:var(--shd-type-telemetry);line-height:1.4}
.mechanic-field-error{padding-left:10px;border-left:2px solid var(--shd-danger)}
@media(max-width:560px){.validation-summary a{grid-template-columns:1fr;gap:1px}}
/* Collapsible modeled-data workspace for capture-first ISAC editors. */
.modeling-disclosure{margin-top:10px;border:1px solid var(--shd-border);background:var(--shd-surface)}
.modeling-disclosure>summary{min-height:52px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 11px;list-style:none;background:var(--shd-surface-raised);cursor:pointer}
.modeling-disclosure>summary::-webkit-details-marker{display:none}
.modeling-disclosure>summary span{display:grid;gap:3px}
.modeling-disclosure>summary b,.modeling-disclosure>summary strong{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.9px}
.modeling-disclosure>summary small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.5px}
.modeling-disclosure>summary strong{color:var(--shd-orange)}
.modeling-disclosure>summary:hover,.modeling-disclosure>summary:focus-visible{background:var(--shd-surface-active);box-shadow:inset 2px 0 var(--shd-orange);outline:none}
.modeling-disclosure[open]>summary{border-bottom:1px solid var(--shd-border);box-shadow:inset 2px 0 var(--shd-orange)}
.modeling-disclosure.validation-area-error{border-color:var(--shd-danger)}
.modeling-disclosure.validation-area-error>summary{box-shadow:inset 2px 0 var(--shd-danger)}
.modeling-content{padding:10px}
@media(max-width:560px){.modeling-disclosure>summary{min-height:56px}.modeling-content{padding:7px}}
@media(prefers-reduced-motion:reduce){.modeling-disclosure>summary{transition:none}}
+13 -1
View File
@@ -43,7 +43,7 @@ const shot = (
describe('trigger action registry', () => {
it('owns every action and validates discriminated parameters', () => {
expect(Object.keys(TRIGGER_ACTION_REGISTRY)).toHaveLength(15);
expect(Object.keys(TRIGGER_ACTION_REGISTRY)).toHaveLength(16);
expect(validateTriggerConfiguration('same_target_hits_within_window', {
hitsRequired: 9,
windowSeconds: 3
@@ -197,6 +197,18 @@ describe('evaluateConditionalProgram', () => {
});
});
it('treats an explicit non-headshot hit as a body shot', () => {
const result = evaluateConditionalProgram(program({
action: 'body_shot',
params: {}
}), [
shot(1, 'alpha', true, false),
shot(2, 'alpha', true, true),
shot(3, 'alpha', false, false)
], { mode: 'encounter', encounterSeconds: 5 });
expect(result.activations.map((activation) => activation.at)).toEqual([1]);
});
it('refreshes one shared duration and clears every stack on expiry', () => {
const result = evaluateConditionalProgram(program({
action: 'damage_taken',
+12
View File
@@ -6,6 +6,7 @@ export const TRIGGER_ACTION_KEYS = [
'while_firing',
'health_threshold_bands',
'headshot',
'body_shot',
'headshot_kill',
'weapon_kill',
'target_hit_within_distance',
@@ -52,6 +53,7 @@ export type TriggerParamsByAction = {
while_firing: EmptyTriggerParams;
health_threshold_bands: HealthThresholdParams;
headshot: EmptyTriggerParams;
body_shot: EmptyTriggerParams;
headshot_kill: EmptyTriggerParams;
weapon_kill: EmptyTriggerParams;
target_hit_within_distance: DistanceParams;
@@ -374,6 +376,12 @@ export const TRIGGER_ACTION_REGISTRY: Readonly<Record<TriggerActionKey, TriggerA
validate: validateEmpty,
summarize: () => 'Land a headshot.'
},
body_shot: {
key: 'body_shot',
label: 'Body shot',
validate: validateEmpty,
summarize: () => 'Land a non-headshot weapon hit.'
},
headshot_kill: {
key: 'headshot_kill',
label: 'Headshot kill',
@@ -611,6 +619,10 @@ function eventCandidates(
return events
.filter((event): event is ShotEvent => event.kind === 'shot' && event.hit && event.headshot === true)
.map((event) => ({ at: event.at, outcomeKey: null }));
case 'body_shot':
return events
.filter((event): event is ShotEvent => event.kind === 'shot' && event.hit && event.headshot === false)
.map((event) => ({ at: event.at, outcomeKey: null }));
case 'headshot_kill':
return events
.filter((event): event is EnemyKilledEvent => event.kind === 'enemy_killed' && event.headshot === true)
@@ -54,6 +54,41 @@ const draft = (): ConditionalMechanicDraftV1 => ({
});
describe('conditional mechanic authoring payload', () => {
it('accepts an empty captured-only program for deferred modeling', () => {
const partial: ConditionalMechanicDraftV1 = {
version: 1,
simulationReadiness: 'captured_only',
scenarioInputs: [],
pools: [],
clauses: []
};
const result = parseConditionalMechanicDraft(partial, {
variantKeys: ['I', 'II']
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.draft).toEqual(partial);
expect(result.value.programs.I.clauses).toEqual([]);
expect(result.value.programs.II.clauses).toEqual([]);
});
it('requires a branch after a partial program is marked calculation-ready', () => {
const partial: ConditionalMechanicDraftV1 = {
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [],
clauses: []
};
const result = parseConditionalMechanicDraft(partial, {
variantKeys: ['I', 'II']
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors).toContain('A simulation-ready mechanic requires at least one clause.');
}
});
it('builds one validated runtime program per exact source variant', () => {
const result = parseConditionalMechanicDraft(draft(), {
variantKeys: ['I', 'II'],
@@ -263,6 +298,98 @@ describe('conditional mechanic authoring payload', () => {
}
});
it('preserves target entity mutations and state-scaled exit outcomes', () => {
const targeted: ConditionalMechanicDraftV1 = {
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [{
key: 'marked-target',
label: 'Marked Target',
stateKind: 'target_mark',
scope: 'target',
initial: 0,
min: 0,
max: 1
}],
clauses: [{
key: 'detonate-mark',
event: 'enemy_killed',
reducer: { kind: 'none' },
mutations: [{
kind: 'set',
pool: 'marked-target',
entity: { kind: 'event_target' },
value: 1
}],
outcomes: [{
key: 'target-damage',
label: 'Target Damage',
phase: 'on_exit',
kind: 'direct_damage',
scope: 'target',
basis: 'flat',
statePool: 'marked-target',
perStack: true,
variants: {
base: {
value: 240,
expression: {
op: 'multiply',
left: 240,
right: {
op: 'divide',
left: {
ref: {
source: 'attribute',
attributeId: 'firepower-multiplier'
}
},
right: 100
}
}
}
}
}]
}]
};
const result = parseConditionalMechanicDraft(targeted, {
variantKeys: ['base'],
allowedAttributeIds: new Set(['firepower-multiplier'])
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.draft).toMatchObject(targeted);
expect(result.value.programs.base).toMatchObject({
pools: [{ key: 'marked-target', scope: 'target' }],
clauses: [{
mutations: [{
kind: 'set',
pool: 'marked-target',
entity: { kind: 'event_target' },
value: 1
}],
outcomes: [{
phase: 'on_exit',
kind: 'direct_damage',
statePool: 'marked-target',
perStack: true,
value: {
right: {
left: {
ref: {
source: 'attribute',
attributeId: 'firepower-multiplier'
}
}
}
}
}]
}]
});
});
it('rejects outcome fields that are absent from the canonical capability manifest', () => {
const unsupported = draft();
(unsupported.clauses[0].outcomes[0] as unknown as Record<string, unknown>).cadence =
@@ -215,6 +215,113 @@ describe('conditional-effect persistence adapter', () => {
expect(result.errors).toContain('Outcome 1 continuous delivery requires a positive duration.');
});
it('preserves target pool mutations, exit outcomes, and live Attribute references', () => {
const firepowerMultiplierId = '82a95611-283b-4fd8-a6ed-3567a316f36e';
const result = adaptConditionalEffectProgram({
rule: rule(),
statePools: [pool({
state_key: 'marked-target',
label: 'Marked Target',
state_kind: 'target_mark',
state_scope: 'target',
maximum_value: 1,
best_case_value: 1
})],
clauses: [clause({
trigger_id: null,
event_kind: 'enemy_killed',
parameters_json: '{}',
mutations_json: JSON.stringify([{
kind: 'set',
pool: 'marked-target',
entity: { kind: 'event_target' },
value: 1
}])
})],
outcomes: [outcome({
effect_key: 'marked-target-detonation',
application_kind: 'direct_damage',
effect_scope: 'target',
stat_attribute_id: null,
value: 240,
value_expression_json: JSON.stringify({
op: 'multiply',
left: 240,
right: {
op: 'divide',
left: {
ref: {
source: 'attribute',
attributeId: firepowerMultiplierId
}
},
right: 100
}
}),
value_scaling: 'per_state_unit',
damage_basis: 'flat',
application_phase: 'on_exit',
min_state_value: null
})],
triggers: []
}, { name: 'Target Detonation', variantKey: 'I' });
expect(result.errors).toEqual([]);
expect(result.program?.pools?.[0]).toMatchObject({
key: 'marked-target',
scope: 'target'
});
expect(result.program?.clauses[0]).toMatchObject({
mutations: [{
kind: 'set',
pool: 'marked-target',
entity: { kind: 'event_target' },
value: 1
}],
outcomes: [{
phase: 'on_exit',
kind: 'direct_damage',
scope: 'target',
statePool: 'marked-target',
perStack: true,
value: {
op: 'multiply',
left: 240,
right: {
op: 'divide',
left: {
ref: {
source: 'attribute',
attributeId: firepowerMultiplierId
}
},
right: 100
}
}
}]
});
});
it('rejects malformed mutation entity selectors from persisted JSON', () => {
const result = adaptConditionalEffectProgram({
rule: rule(),
statePools: [pool({ state_scope: 'target' })],
clauses: [clause({
mutations_json: JSON.stringify([{
kind: 'set',
pool: 'effect',
entity: { kind: 'event_field', path: '' },
value: 1
}])
})],
outcomes: [outcome()],
triggers: [{ id: 'trigger-1', action_key: 'same_target_hits_within_window' }]
}, { variantKey: 'I' });
expect(result.status).toBe('INVALID');
expect(result.errors).toContain('Clause 1 mutations must be an array of valid state mutations.');
});
it('adapts the Happy Survival branches, explicit cadence, health timing, and upgrade references', () => {
const upgradeScale = {
op: 'add',
+43 -14
View File
@@ -174,20 +174,43 @@ function requiredNumber(value: DatabaseValue, path: string, errors: string[]) {
return result ?? 0;
}
function isEntitySelector(value: unknown) {
if (!object(value) || typeof value.kind !== 'string') return false;
if (value.kind === 'agent' || value.kind === 'event_actor' || value.kind === 'event_target') {
return true;
}
return value.kind === 'event_field'
&& typeof value.path === 'string'
&& Boolean(value.path.trim());
}
function isValueReference(value: unknown) {
if (!object(value) || typeof value.source !== 'string') return false;
if (value.source === 'event' || value.source === 'equipment') {
return typeof value.path === 'string' && Boolean(value.path.trim());
}
if (value.source === 'combat') {
return typeof value.path === 'string'
&& Boolean(value.path.trim())
&& (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current');
}
if (value.source === 'attribute') {
return typeof value.attributeId === 'string' && Boolean(value.attributeId.trim());
}
if (value.source === 'scenario') {
return typeof value.key === 'string' && Boolean(value.key.trim());
}
if (value.source === 'reducer') return true;
return value.source === 'pool'
&& typeof value.pool === 'string'
&& Boolean(value.pool.trim())
&& (value.entity === undefined || isEntitySelector(value.entity));
}
function isNumericExpression(value: unknown): value is NumericExpression {
if (finite(value)) return true;
if (!object(value)) return false;
if ('ref' in value) {
if (!object(value.ref) || typeof value.ref.source !== 'string') return false;
const source = value.ref.source;
if (source === 'event' || source === 'combat' || source === 'equipment') {
return typeof value.ref.path === 'string';
}
if (source === 'scenario') return typeof value.ref.key === 'string';
if (source === 'reducer') return true;
if (source === 'pool') return typeof value.ref.pool === 'string';
return false;
}
if ('ref' in value) return isValueReference(value.ref);
if (value.op === 'clamp') {
return isNumericExpression(value.value)
&& isNumericExpression(value.min)
@@ -233,14 +256,20 @@ function isReducer(value: unknown): value is Reducer {
function isMutation(value: unknown): value is ClauseMutation {
if (!object(value) || typeof value.kind !== 'string') return false;
if (value.kind === 'none') return true;
if (value.kind === 'clear') return typeof value.pool === 'string';
if (value.kind === 'clear') {
return typeof value.pool === 'string'
&& (value.entity === undefined || isEntitySelector(value.entity));
}
if (value.kind === 'transfer') {
return typeof value.pool === 'string' && object(value.from) && object(value.to)
return typeof value.pool === 'string'
&& isEntitySelector(value.from)
&& isEntitySelector(value.to)
&& (value.value === undefined || value.value === 'all' || isNumericExpression(value.value));
}
return ['add', 'set', 'consume', 'derive'].includes(value.kind)
&& typeof value.pool === 'string'
&& isNumericExpression(value.value);
&& isNumericExpression(value.value)
&& (value.entity === undefined || isEntitySelector(value.entity));
}
function parseScenarioInputs(
@@ -25,6 +25,7 @@ const paramsByAction: Record<TriggerActionKey, unknown> = {
]
},
headshot: {},
body_shot: {},
headshot_kill: {},
weapon_kill: {},
target_hit_within_distance: { meters: 15 },
@@ -96,6 +97,27 @@ describe('mechanic recipe registry', () => {
});
});
it('maps head and body shots to authoritative weapon-hit predicates', () => {
for (const [action, headshot] of [['headshot', true], ['body_shot', false]] as const) {
expect(seedFor(action).clauses[0]).toMatchObject({
when: 'weapon_hit',
if: {
kind: 'all',
predicates: [
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
{ kind: 'exists', value: { source: 'event', path: 'values.headshot' } },
{
kind: 'compare',
left: { source: 'event', path: 'values.headshot' },
operator: 'eq',
right: headshot
}
]
}
});
}
});
it('models firing triggers as explicit lifecycle branches', () => {
const continuous = seedFor('continuous_firing_duration');
expect(continuous.pools).toEqual([expect.objectContaining({ key: 'firing-active', max: 1 })]);
+17 -1
View File
@@ -251,7 +251,23 @@ export const MECHANIC_RECIPE_REGISTRY: Readonly<MechanicRecipeRegistry> = {
headshot: {
key: 'headshot',
createSeed: (_params) => supported({ clauses: [directClause('headshot', 'weapon_hit', {
kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: true
kind: 'all',
predicates: [
targetExists,
{ kind: 'exists', value: eventValue('headshot') },
{ kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: true }
]
})] })
},
body_shot: {
key: 'body_shot',
createSeed: (_params) => supported({ clauses: [directClause('body-shot', 'weapon_hit', {
kind: 'all',
predicates: [
targetExists,
{ kind: 'exists', value: eventValue('headshot') },
{ kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: false }
]
})] })
},
headshot_kill: {
+156
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
MECHANIC_EVENT_REGISTRY,
evaluateMechanicProgram,
formatMechanicProgram,
validateMechanicProgram,
@@ -14,6 +15,16 @@ const custom = (encounterSeconds = 30, inputs: Record<string, number | boolean>
});
describe('structured mechanic runtime', () => {
it('advertises the authoritative weapon-hit target and headshot fields', () => {
expect(MECHANIC_EVENT_REGISTRY.weapon_hit).toMatchObject({
description: expect.stringContaining('headshot boolean'),
fields: [
{ path: 'targetId', label: 'Target ID', type: 'string' },
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' }
]
});
});
it('models Superior Firepower as same-target sliding hits and one shared expiring stack pool', () => {
const superiorFirepower: MechanicProgram = {
id: 'superior-firepower',
@@ -512,6 +523,151 @@ describe('structured mechanic runtime', () => {
]);
});
it('caps and refreshes target-scoped stacks independently', () => {
const program: MechanicProgram = {
id: 'target-trauma',
name: 'Target Trauma',
pools: [{
key: 'trauma',
label: 'Trauma',
scope: 'target',
initial: 0,
min: 0,
max: 10,
expiresAfterSeconds: 1
}],
clauses: [
{
id: 'body-stack',
when: 'weapon_hit',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.headshot' },
operator: 'eq',
right: false
},
reducer: { kind: 'none' },
mutations: [{
kind: 'add',
pool: 'trauma',
entity: { kind: 'event_target' },
value: 1
}]
},
{
id: 'head-stack',
when: 'weapon_hit',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.headshot' },
operator: 'eq',
right: true
},
reducer: { kind: 'none' },
mutations: [{
kind: 'add',
pool: 'trauma',
entity: { kind: 'event_target' },
value: 2
}]
}
]
};
const result = evaluateMechanicProgram(program, [
{ kind: 'weapon_hit', at: 0, targetId: 'enemy-a', values: { headshot: false } },
{ kind: 'weapon_hit', at: 0.25, targetId: 'enemy-b', values: { headshot: false } },
{ kind: 'weapon_hit', at: 0.5, targetId: 'enemy-a', values: { headshot: true } },
...Array.from({ length: 5 }, (_, index): MechanicEvent => ({
kind: 'weapon_hit',
at: 0.55 + index * 0.05,
targetId: 'enemy-a',
values: { headshot: true }
}))
], custom(0.9));
expect(result.status).toBe('OK');
expect(result.finalState).toEqual([
expect.objectContaining({
pool: 'trauma',
entityId: 'enemy-a',
value: 10,
expiresAt: 1.75
}),
expect.objectContaining({
pool: 'trauma',
entityId: 'enemy-b',
value: 1,
expiresAt: 1.25
})
]);
});
it('uses the prior target stack count for live-Attribute damage on pool exit', () => {
const skillDamageId = 'skill-damage';
const program: MechanicProgram = {
id: 'precise-trauma',
name: 'Precise Trauma',
pools: [{
key: 'trauma',
label: 'Trauma',
scope: 'target',
initial: 0,
min: 0,
max: 10,
expiresAfterSeconds: 1
}],
clauses: [{
id: 'apply-trauma',
when: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [{
kind: 'add',
pool: 'trauma',
entity: { kind: 'event_target' },
value: 2
}],
outcomes: [{
id: 'trauma-damage',
label: 'Trauma Damage',
phase: 'on_exit',
kind: 'direct_damage',
scope: 'target',
basis: 'flat',
value: { ref: { source: 'attribute', attributeId: skillDamageId } },
statePool: 'trauma',
perStack: true,
minStacks: 2
}]
}]
};
const result = evaluateMechanicProgram(
program,
[{ kind: 'weapon_hit', at: 0, targetId: 'enemy-a', values: { headshot: true } }],
custom(2),
{
maxHealth: 100,
currentHealth: 100,
attributes: { [skillDamageId]: 75 }
}
);
expect(result.status).toBe('OK');
expect(result.outcomes).toMatchObject([{
outcomeId: 'trauma-damage',
targetId: 'enemy-a',
at: 1,
phase: 'on_exit',
value: 150,
resolvedValue: 150,
appliedValue: 150,
stateValue: 2
}]);
expect(result.finalState).toContainEqual(expect.objectContaining({
pool: 'trauma',
entityId: 'enemy-a',
value: 0,
expiresAt: null
}));
});
it('models scaled OS Protocol restoration, an unscaled Health cost, and post-cost bonus damage', () => {
const upgradeScale = {
op: 'add' as const,
+28 -6
View File
@@ -25,6 +25,11 @@ export type MechanicEventDefinition = {
key: MechanicEventKind;
label: string;
description: string;
fields?: readonly {
path: string;
label: string;
type: 'boolean' | 'number' | 'string';
}[];
};
export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, MechanicEventDefinition>> = {
@@ -36,7 +41,11 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
weapon_hit: {
key: 'weapon_hit',
label: 'Weapon hit',
description: 'A weapon attack connects with a target. Critical and distance details belong in event values.'
description: 'A weapon attack connects with a target. The headshot boolean distinguishes headshots from body shots.',
fields: [
{ path: 'targetId', label: 'Target ID', type: 'string' },
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' }
]
},
skill_deployed: {
key: 'skill_deployed',
@@ -326,6 +335,7 @@ export type MechanicOutcomeEmission = {
clauseId: string;
outcomeId: string;
at: number;
targetId: string | null;
phase: OutcomePhase;
kind: MechanicOutcome['kind'];
scope: MechanicOutcome['scope'];
@@ -401,6 +411,7 @@ type EvaluationContext = {
type ContinuousDelivery = {
clauseId: string;
outcomeId: string;
targetId: string | null;
phase: OutcomePhase;
scope: MechanicOutcomeScope;
resource: MechanicResource;
@@ -895,14 +906,22 @@ function emitOutcomes(
continuousDeliveries?: ContinuousDelivery[]
): MechanicOutcomeEmission[] {
const emissions: MechanicOutcomeEmission[] = [];
const stateValues = (outcome: MechanicOutcome) => {
const current = outcomeState(outcome, context);
const previous = outcome.statePool ? before.get(outcome.statePool) ?? current : current;
return {
current,
previous,
effective: outcome.phase === 'on_exit' ? previous : current
};
};
const eligible = (clause.outcomes ?? []).filter((outcome) => {
if (phase === 'before' && outcome.phase !== 'on_event') return false;
if (phase === 'after' && outcome.phase === 'on_event') return false;
const current = outcomeState(outcome, context);
const previous = outcome.statePool ? before.get(outcome.statePool) ?? current : current;
const { current, previous, effective } = stateValues(outcome);
if (!phaseMatches(outcome.phase, previous, current)) return false;
if (outcome.minStacks !== undefined && current < outcome.minStacks) return false;
if (outcome.maxStacks !== undefined && current > outcome.maxStacks) return false;
if (outcome.minStacks !== undefined && effective < outcome.minStacks) return false;
if (outcome.maxStacks !== undefined && effective > outcome.maxStacks) return false;
return true;
}).sort((left, right) => {
const priority = (outcome: MechanicOutcome) =>
@@ -910,7 +929,7 @@ function emitOutcomes(
return priority(left) - priority(right);
});
for (const outcome of eligible) {
const current = outcomeState(outcome, context);
const { effective: current } = stateValues(outcome);
const baseValue = numericValue(outcome.value, context);
const value = outcome.perStack ? baseValue * current : baseValue;
const resolvedValue = resolveOutcomeValue(outcome, value, context);
@@ -925,6 +944,7 @@ function emitOutcomes(
continuousDeliveries?.push({
clauseId: clause.id,
outcomeId: outcome.id,
targetId: context.event.targetId ?? null,
phase: outcome.phase,
scope: outcome.scope,
resource: outcomeResource(outcome),
@@ -957,6 +977,7 @@ function emitOutcomes(
clauseId: clause.id,
outcomeId: outcome.id,
at,
targetId: context.event.targetId ?? null,
phase: outcome.phase,
kind: outcome.kind,
scope: outcome.scope,
@@ -1007,6 +1028,7 @@ function advanceContinuousDeliveries(
clauseId: delivery.clauseId,
outcomeId: delivery.outcomeId,
at: through,
targetId: delivery.targetId,
phase: delivery.phase,
kind: 'restoration',
scope: delivery.scope,
@@ -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}
@@ -78,7 +78,7 @@ describe('AI mechanic authoring prompt', () => {
recipeKeys: []
}));
expect(AI_MECHANIC_PROMPT_VERSION).toBe(4);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(5);
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
expect.arrayContaining(['delivery', 'durationSeconds'])
);
@@ -115,7 +115,7 @@ describe('AI mechanic authoring prompt', () => {
expect.stringContaining('Do not replace continuous delivery with a periodic reducer')
]));
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
'Prompt contract version: 4'
'Prompt contract version: 5'
);
const advertised = structuredClone(
@@ -156,4 +156,89 @@ describe('AI mechanic authoring prompt', () => {
});
expect(parsed.ok).toBe(true);
});
it('advertises Precise Trauma through target expiry without inventing target tick iteration', () => {
const skillDamageId = 'skill-damage-catalog-id';
const input = JSON.parse(buildAiMechanicAuthoringInput({
request: {
...request,
context: {
...request.context,
sourceName: 'Precise Trauma',
sourceDescription:
'Hits apply a debuff for 1 second, up to 10 stacks. Body shots add 1 stack and headshots add 2 stacks.'
}
},
attributes: [{
id: skillDamageId,
name: 'Skill Damage',
description: null,
unit: null,
type: null,
category: null,
calculationType: null
}],
events: [{ key: 'weapon_hit', label: 'Weapon hit' }],
actions: [
{ key: 'body_shot', label: 'Body shot' },
{ key: 'headshot', label: 'Headshot' }
],
recipeKeys: ['body_shot', 'headshot']
}));
const advertised = structuredClone(input.canonicalCatalog.grammar.preciseTraumaShape);
const outcome = advertised.clauses[0].outcomes[0];
const baseVariant = JSON.parse(JSON.stringify(
outcome.variants['replace-with-each-required-variant-key']
).replaceAll(
'replace-with-the-exact-skill-damage-catalog-id',
skillDamageId
));
outcome.variants = {
I: structuredClone(baseVariant),
II: structuredClone(baseVariant)
};
expect(advertised.pools[0]).toMatchObject({
scope: 'target',
max: 10,
expiresAfterSeconds: 1
});
expect(advertised.clauses[0].mutations[0]).toEqual({
kind: 'add',
pool: 'trauma',
entity: { kind: 'event_target' },
value: 1
});
expect(advertised.clauses[1].mutations[0]).toMatchObject({ value: 2 });
expect(outcome).toMatchObject({
phase: 'on_exit',
kind: 'direct_damage',
scope: 'target',
statePool: 'trauma',
perStack: true,
variants: {
I: {
expression: {
ref: {
source: 'attribute',
attributeId: skillDamageId
}
}
}
}
});
expect(input.examplesAndRules).toEqual(expect.arrayContaining([
expect.stringContaining('uses the entity previous state'),
expect.stringContaining('without inventing generic repeated target ticks')
]));
expect(JSON.stringify(input.canonicalCatalog.grammar.preciseTraumaShape))
.not.toContain('entitiesFromPool');
const parsed = parseConditionalMechanicDraft(advertised, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set([skillDamageId]),
allowedRecipeKeys: new Set(['body_shot', 'headshot'])
});
expect(parsed.ok).toBe(true);
});
});
+106 -2
View File
@@ -2,7 +2,7 @@ import type { AiMechanicAuthoringRequest } from '$lib/ai/mechanic-authoring';
import { CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES } from '$lib/calculations/mechanic-editor';
import type { AiMechanicAttribute } from './mechanic-authoring-context';
export const AI_MECHANIC_PROMPT_VERSION = 4;
export const AI_MECHANIC_PROMPT_VERSION = 5;
const mechanicsGrammar = {
version: 1,
@@ -19,7 +19,9 @@ const mechanicsGrammar = {
scope: ['agent', 'target'],
optional: ['min', 'max', 'bestCaseValue', 'expiresAfterSeconds'],
expirationSemantics:
'expiresAfterSeconds is one shared pool timer. Every write to the pool resets that timer for the entire pool, and expiry resets the full pool to its initial value.'
'expiresAfterSeconds is one timer per pool entity. Every write resets that entity timer, and expiry resets only that entity to the pool initial value.',
targetScopeSemantics:
'A target-scoped pool stores one independent value and expiry timer per resolved entity. Use mutation.entity event_target when a weapon-hit mechanic binds state to the enemy that was hit.'
},
clause: {
required: ['key', 'event', 'if', 'reducer', 'mutations', 'outcomes'],
@@ -94,6 +96,12 @@ const mechanicsGrammar = {
mutationShapes: [
{ kind: 'none' },
{ kind: 'add', pool: 'stacks', value: 1 },
{
kind: 'add',
pool: 'target-stacks',
entity: { kind: 'event_target' },
value: 1
},
{ kind: 'set', pool: 'active', value: 1 },
{ kind: 'consume', pool: 'stacks', value: 1 },
{ kind: 'derive', pool: 'nearby-enemies', value: { ref: { source: 'reducer' } } },
@@ -138,6 +146,98 @@ const mechanicsGrammar = {
}
}
},
preciseTraumaShape: {
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [{
key: 'trauma',
label: 'Trauma',
stateKind: 'stack',
scope: 'target',
initial: 0,
min: 0,
max: 10,
bestCaseValue: 10,
expiresAfterSeconds: 1
}],
clauses: [
{
key: 'body-shot-trauma',
label: 'Body shot adds one Trauma stack',
recipeKey: 'body_shot',
event: 'weapon_hit',
if: {
kind: 'all',
predicates: [
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
{ kind: 'exists', value: { source: 'event', path: 'values.headshot' } },
{
kind: 'compare',
left: { source: 'event', path: 'values.headshot' },
operator: 'eq',
right: false
}
]
},
reducer: { kind: 'none' },
mutations: [{
kind: 'add',
pool: 'trauma',
entity: { kind: 'event_target' },
value: 1
}],
outcomes: [{
key: 'trauma-skill-damage',
label: 'Trauma Skill Damage',
phase: 'on_exit',
kind: 'direct_damage',
scope: 'target',
basis: 'flat',
statePool: 'trauma',
perStack: true,
variants: {
'replace-with-each-required-variant-key': {
value: 0,
expression: {
ref: {
source: 'attribute',
attributeId: 'replace-with-the-exact-skill-damage-catalog-id'
}
}
}
}
}]
},
{
key: 'headshot-trauma',
label: 'Headshot adds two Trauma stacks',
recipeKey: 'headshot',
event: 'weapon_hit',
if: {
kind: 'all',
predicates: [
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
{ kind: 'exists', value: { source: 'event', path: 'values.headshot' } },
{
kind: 'compare',
left: { source: 'event', path: 'values.headshot' },
operator: 'eq',
right: true
}
]
},
reducer: { kind: 'none' },
mutations: [{
kind: 'add',
pool: 'trauma',
entity: { kind: 'event_target' },
value: 2
}],
outcomes: []
}
]
},
variantValue: {
required: ['value'],
optional: ['expression']
@@ -241,6 +341,10 @@ export function buildAiMechanicAuthoringInput(input: {
'An Attribute whose catalog unit is % resolves to its displayed percentage number, such as 20 for 20%. Divide that reference by 100 when wording applies it as a percentage multiplier, such as 240 × 20%.',
'Continuous Health restoration is supported. Use kind:"restoration", resource:"health", delivery:"continuous", and a positive durationSeconds. The variant expression resolves the total restoration across that duration, not a per-second value. Do not replace continuous delivery with a periodic reducer.',
'Each continuous-restoration activation creates an independent constant-rate stream. Overlapping streams add, and restoration above the resource maximum is lost when it occurs.',
'A target-scoped pool owns an independent value and expiry timer for each entity. Bind hit-created target state with entity:{kind:"event_target"}; do not collapse different enemies into one Agent-scoped pool.',
'Writing a target-scoped pool refreshes only that target entity timer. An on_exit outcome runs when that entity expires and uses the entity previous state for stack bands and perStack multiplication, even though the stored pool value has already reset.',
'When an outcome value is exactly a live Attribute amount, use the Attribute reference directly as the variant expression. Divide by 100 only when a displayed percentage is being used as a multiplier.',
'Precise Trauma is representable as one target-scoped stack pool with a one-second expiry: body shots add one stack, headshots add two, and one on_exit direct-damage outcome uses the expiring target previous stack count. This models the stated one-second debuff without inventing generic repeated target ticks.',
'OS Protocol upgrade scaling may use equipment.protocolTalentUpgrades only when allowProtocolUpgradeScaling is true.',
'Assigned-ability scope may be used only when allowAssignedAbility is true.',
'If cadence is not stated, ask rather than assuming a one-second interval.',
+97 -3
View File
@@ -182,14 +182,27 @@ describe('conditional mechanic persistence adapter', () => {
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [],
pools: [{
key: 'marked-target',
label: 'Marked Target',
stateKind: 'target_mark',
scope: 'target',
initial: 0,
min: 0,
max: 1
}],
clauses: [{
key: 'weapon-kill',
label: 'Weapon kill',
event: 'enemy_killed',
if: { kind: 'true' },
reducer: { kind: 'none' },
mutations: [],
mutations: [{
kind: 'set',
pool: 'marked-target',
entity: { kind: 'event_target' },
value: 1
}],
outcomes: [{
key: 'restore-health',
label: 'Restore Health',
@@ -204,6 +217,51 @@ describe('conditional mechanic persistence adapter', () => {
I: { value: 240 },
II: { value: 336 }
}
}, {
key: 'target-detonation',
label: 'Target Detonation',
phase: 'on_exit',
kind: 'direct_damage',
scope: 'target',
basis: 'flat',
statePool: 'marked-target',
perStack: true,
variants: {
I: {
value: 240,
expression: {
op: 'multiply',
left: 240,
right: {
op: 'divide',
left: {
ref: {
source: 'attribute',
attributeId: '82a95611-283b-4fd8-a6ed-3567a316f36e'
}
},
right: 100
}
}
},
II: {
value: 336,
expression: {
op: 'multiply',
left: 336,
right: {
op: 'divide',
left: {
ref: {
source: 'attribute',
attributeId: '82a95611-283b-4fd8-a6ed-3567a316f36e'
}
},
right: 100
}
}
}
}
}]
}]
});
@@ -213,7 +271,9 @@ describe('conditional mechanic persistence adapter', () => {
)).all();
expect(saved).toEqual([
{ delivery_mode: 'continuous', delivery_duration_seconds: 3 },
{ delivery_mode: 'continuous', delivery_duration_seconds: 3 }
{ delivery_mode: 'instant', delivery_duration_seconds: null },
{ delivery_mode: 'continuous', delivery_duration_seconds: 3 },
{ delivery_mode: 'instant', delivery_duration_seconds: null }
]);
const loaded = await loadConditionalRuleDraft(
database,
@@ -229,5 +289,39 @@ describe('conditional mechanic persistence adapter', () => {
II: { value: 336 }
}
});
expect(loaded?.pools[0]).toMatchObject({
key: 'marked-target',
stateKind: 'target_mark',
scope: 'target'
});
expect(loaded?.clauses[0].mutations).toEqual([{
kind: 'set',
pool: 'marked-target',
entity: { kind: 'event_target' },
value: 1
}]);
expect(loaded?.clauses[0].outcomes[1]).toMatchObject({
phase: 'on_exit',
kind: 'direct_damage',
scope: 'target',
statePool: 'marked-target',
perStack: true,
variants: {
I: {
value: 240,
expression: expect.objectContaining({
right: expect.objectContaining({
left: {
ref: {
source: 'attribute',
attributeId: '82a95611-283b-4fd8-a6ed-3567a316f36e'
}
}
})
})
},
II: { value: 336 }
}
});
});
});
@@ -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;
}
+10 -5
View File
@@ -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.' });
}
+12 -23
View File
@@ -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,7 +126,12 @@
</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'])}>
<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'}]}
@@ -157,6 +145,7 @@
/>
{#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>