Support target-scoped trauma mechanics
This commit is contained in:
+21
-8
@@ -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
|
panels and restrained borders to preserve the hierarchy; do not present the
|
||||||
typed graph as an undifferentiated form.
|
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
|
The direct-event picker reads the 19 normalized events from the code-defined
|
||||||
event registry. Use searchable single-assignment because the vocabulary exceeds
|
event registry. Use searchable single-assignment because the vocabulary exceeds
|
||||||
the compact segmented-choice limit. Each branch presents its event, match
|
the compact segmented-choice limit. Each branch presents its event, match
|
||||||
policy and parameters, optional cooldown, state mutations, and outcomes.
|
policy and parameters, optional cooldown, state mutations, and outcomes.
|
||||||
Recipe-generated predicates and weights remain visible in the generated
|
Recipe-generated predicates and weights remain visible in the generated
|
||||||
script. State rows expose label, agent or target scope, initial value, bounds,
|
script. State rows expose label, agent or target scope, initial value, bounds,
|
||||||
best-case value, and optional expiry. Outcome rows expose kind, target scope,
|
best-case value, and optional expiry. A state change that writes a target pool
|
||||||
Attribute or resource when applicable, value basis, state scaling, phase, and
|
uses a `STATE OWNER` segmented control with `EVENT TARGET` and `EVENT ACTOR`;
|
||||||
every source-specific value column. The Value Model control supports static
|
the selected owner remains visible in the generated script. Outcome rows expose
|
||||||
values, live Attribute percentages, event fields, reducer output, source-defined
|
kind, target scope, Attribute or resource when applicable, value basis, state
|
||||||
scenario controls, and OS Protocol upgrade scaling where the source permits it.
|
scaling, phase, and every source-specific value column. The Value Model control
|
||||||
Selecting a live Attribute uses the shared searchable single-assignment pattern
|
supports static values, live Attribute totals, percentage-normalized Attribute
|
||||||
and displays the Attribute's unit. Label the percentage-normalized model
|
values, event fields, reducer output, source-defined scenario controls, and OS
|
||||||
`ATTRIBUTE ÷ 100` so authors can see the transform applied to the stored value.
|
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
|
Restoration outcomes also expose an
|
||||||
`INSTANT` or `CONTINUOUS` segmented choice. Continuous delivery reveals one
|
`INSTANT` or `CONTINUOUS` segmented choice. Continuous delivery reveals one
|
||||||
required duration field and describes the configured value as the total
|
required duration field and describes the configured value as the total
|
||||||
|
|||||||
+21
-3
@@ -398,7 +398,10 @@ normalized events are `shot_fired`, `weapon_hit`, `skill_deployed`,
|
|||||||
`movement_snapshot`, `proximity_snapshot`, `firing_started`, `firing_stopped`,
|
`movement_snapshot`, `proximity_snapshot`, `firing_started`, `firing_stopped`,
|
||||||
`reload_started`, `reload_completed`, `weapon_swapped`, and `tick`. Event
|
`reload_started`, `reload_completed`, `weapon_swapped`, and `tick`. Event
|
||||||
details such as target, distance, critical state, source, movement, and nearby
|
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
|
The optional Action Family catalog provides authoring recipes for common
|
||||||
mechanics. Applying a recipe copies its typed scenario inputs, state pools, and
|
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
|
recipe requires confirmation because replacement discards the current branch
|
||||||
graph.
|
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:
|
The program graph uses three child tables:
|
||||||
|
|
||||||
- `conditional_effect_state_pools` defines agent- or target-scoped flags,
|
- `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
|
state mutations, and optional recipe provenance. Reducers convert event
|
||||||
streams into counts, distinct or consecutive matches, sliding windows,
|
streams into counts, distinct or consecutive matches, sliding windows,
|
||||||
durations, intervals, or derived values. Mutations can set, add, consume,
|
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,
|
- `conditional_effect_outcomes` applies Attribute modifiers, direct damage,
|
||||||
restoration, resource costs, or markers. Each outcome names its target scope,
|
restoration, resource costs, or markers. Each outcome names its target scope,
|
||||||
value basis, optional resource or Attribute, combat timing, optional state
|
value basis, optional resource or Attribute, combat timing, optional state
|
||||||
band, and phase: `on_event`, `after_event`, `on_enter`, `on_exit`, or
|
band, and phase: `on_event`, `after_event`, `on_enter`, `on_exit`, or
|
||||||
`while_active`. One typed value or expression exists for every variant
|
`while_active`. One typed value or expression exists for every variant
|
||||||
required by the owning source. The common value models are static values,
|
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
|
source-defined scenario values, and OS Protocol upgrade scaling. Restoration
|
||||||
outcomes may deliver their total immediately or continuously over an explicit
|
outcomes may deliver their total immediately or continuously over an explicit
|
||||||
duration.
|
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,
|
state mutations, enforces pool bounds and expiry, emits outcomes in phase order,
|
||||||
and records telemetry for each activation.
|
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
|
Live Attribute references use a stable Attribute ID and read the authoritative
|
||||||
loadout total that the builder captures for the evaluation pass. Percentage
|
loadout total that the builder captures for the evaluation pass. Percentage
|
||||||
Attributes retain their displayed units, so an expression that applies a
|
Attributes retain their displayed units, so an expression that applies a
|
||||||
|
|||||||
+16
@@ -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}
|
.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)}
|
.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}}
|
@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}}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const shot = (
|
|||||||
|
|
||||||
describe('trigger action registry', () => {
|
describe('trigger action registry', () => {
|
||||||
it('owns every action and validates discriminated parameters', () => {
|
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', {
|
expect(validateTriggerConfiguration('same_target_hits_within_window', {
|
||||||
hitsRequired: 9,
|
hitsRequired: 9,
|
||||||
windowSeconds: 3
|
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', () => {
|
it('refreshes one shared duration and clears every stack on expiry', () => {
|
||||||
const result = evaluateConditionalProgram(program({
|
const result = evaluateConditionalProgram(program({
|
||||||
action: 'damage_taken',
|
action: 'damage_taken',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const TRIGGER_ACTION_KEYS = [
|
|||||||
'while_firing',
|
'while_firing',
|
||||||
'health_threshold_bands',
|
'health_threshold_bands',
|
||||||
'headshot',
|
'headshot',
|
||||||
|
'body_shot',
|
||||||
'headshot_kill',
|
'headshot_kill',
|
||||||
'weapon_kill',
|
'weapon_kill',
|
||||||
'target_hit_within_distance',
|
'target_hit_within_distance',
|
||||||
@@ -52,6 +53,7 @@ export type TriggerParamsByAction = {
|
|||||||
while_firing: EmptyTriggerParams;
|
while_firing: EmptyTriggerParams;
|
||||||
health_threshold_bands: HealthThresholdParams;
|
health_threshold_bands: HealthThresholdParams;
|
||||||
headshot: EmptyTriggerParams;
|
headshot: EmptyTriggerParams;
|
||||||
|
body_shot: EmptyTriggerParams;
|
||||||
headshot_kill: EmptyTriggerParams;
|
headshot_kill: EmptyTriggerParams;
|
||||||
weapon_kill: EmptyTriggerParams;
|
weapon_kill: EmptyTriggerParams;
|
||||||
target_hit_within_distance: DistanceParams;
|
target_hit_within_distance: DistanceParams;
|
||||||
@@ -374,6 +376,12 @@ export const TRIGGER_ACTION_REGISTRY: Readonly<Record<TriggerActionKey, TriggerA
|
|||||||
validate: validateEmpty,
|
validate: validateEmpty,
|
||||||
summarize: () => 'Land a headshot.'
|
summarize: () => 'Land a headshot.'
|
||||||
},
|
},
|
||||||
|
body_shot: {
|
||||||
|
key: 'body_shot',
|
||||||
|
label: 'Body shot',
|
||||||
|
validate: validateEmpty,
|
||||||
|
summarize: () => 'Land a non-headshot weapon hit.'
|
||||||
|
},
|
||||||
headshot_kill: {
|
headshot_kill: {
|
||||||
key: 'headshot_kill',
|
key: 'headshot_kill',
|
||||||
label: 'Headshot kill',
|
label: 'Headshot kill',
|
||||||
@@ -611,6 +619,10 @@ function eventCandidates(
|
|||||||
return events
|
return events
|
||||||
.filter((event): event is ShotEvent => event.kind === 'shot' && event.hit && event.headshot === true)
|
.filter((event): event is ShotEvent => event.kind === 'shot' && event.hit && event.headshot === true)
|
||||||
.map((event) => ({ at: event.at, outcomeKey: null }));
|
.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':
|
case 'headshot_kill':
|
||||||
return events
|
return events
|
||||||
.filter((event): event is EnemyKilledEvent => event.kind === 'enemy_killed' && event.headshot === true)
|
.filter((event): event is EnemyKilledEvent => event.kind === 'enemy_killed' && event.headshot === true)
|
||||||
|
|||||||
@@ -54,6 +54,41 @@ const draft = (): ConditionalMechanicDraftV1 => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('conditional mechanic authoring payload', () => {
|
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', () => {
|
it('builds one validated runtime program per exact source variant', () => {
|
||||||
const result = parseConditionalMechanicDraft(draft(), {
|
const result = parseConditionalMechanicDraft(draft(), {
|
||||||
variantKeys: ['I', 'II'],
|
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', () => {
|
it('rejects outcome fields that are absent from the canonical capability manifest', () => {
|
||||||
const unsupported = draft();
|
const unsupported = draft();
|
||||||
(unsupported.clauses[0].outcomes[0] as unknown as Record<string, unknown>).cadence =
|
(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.');
|
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', () => {
|
it('adapts the Happy Survival branches, explicit cadence, health timing, and upgrade references', () => {
|
||||||
const upgradeScale = {
|
const upgradeScale = {
|
||||||
op: 'add',
|
op: 'add',
|
||||||
|
|||||||
@@ -174,20 +174,43 @@ function requiredNumber(value: DatabaseValue, path: string, errors: string[]) {
|
|||||||
return result ?? 0;
|
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 {
|
function isNumericExpression(value: unknown): value is NumericExpression {
|
||||||
if (finite(value)) return true;
|
if (finite(value)) return true;
|
||||||
if (!object(value)) return false;
|
if (!object(value)) return false;
|
||||||
if ('ref' in value) {
|
if ('ref' in value) return isValueReference(value.ref);
|
||||||
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 (value.op === 'clamp') {
|
if (value.op === 'clamp') {
|
||||||
return isNumericExpression(value.value)
|
return isNumericExpression(value.value)
|
||||||
&& isNumericExpression(value.min)
|
&& isNumericExpression(value.min)
|
||||||
@@ -233,14 +256,20 @@ function isReducer(value: unknown): value is Reducer {
|
|||||||
function isMutation(value: unknown): value is ClauseMutation {
|
function isMutation(value: unknown): value is ClauseMutation {
|
||||||
if (!object(value) || typeof value.kind !== 'string') return false;
|
if (!object(value) || typeof value.kind !== 'string') return false;
|
||||||
if (value.kind === 'none') return true;
|
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') {
|
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));
|
&& (value.value === undefined || value.value === 'all' || isNumericExpression(value.value));
|
||||||
}
|
}
|
||||||
return ['add', 'set', 'consume', 'derive'].includes(value.kind)
|
return ['add', 'set', 'consume', 'derive'].includes(value.kind)
|
||||||
&& typeof value.pool === 'string'
|
&& typeof value.pool === 'string'
|
||||||
&& isNumericExpression(value.value);
|
&& isNumericExpression(value.value)
|
||||||
|
&& (value.entity === undefined || isEntitySelector(value.entity));
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseScenarioInputs(
|
function parseScenarioInputs(
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const paramsByAction: Record<TriggerActionKey, unknown> = {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
headshot: {},
|
headshot: {},
|
||||||
|
body_shot: {},
|
||||||
headshot_kill: {},
|
headshot_kill: {},
|
||||||
weapon_kill: {},
|
weapon_kill: {},
|
||||||
target_hit_within_distance: { meters: 15 },
|
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', () => {
|
it('models firing triggers as explicit lifecycle branches', () => {
|
||||||
const continuous = seedFor('continuous_firing_duration');
|
const continuous = seedFor('continuous_firing_duration');
|
||||||
expect(continuous.pools).toEqual([expect.objectContaining({ key: 'firing-active', max: 1 })]);
|
expect(continuous.pools).toEqual([expect.objectContaining({ key: 'firing-active', max: 1 })]);
|
||||||
|
|||||||
@@ -251,7 +251,23 @@ export const MECHANIC_RECIPE_REGISTRY: Readonly<MechanicRecipeRegistry> = {
|
|||||||
headshot: {
|
headshot: {
|
||||||
key: 'headshot',
|
key: 'headshot',
|
||||||
createSeed: (_params) => supported({ clauses: [directClause('headshot', 'weapon_hit', {
|
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: {
|
headshot_kill: {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
MECHANIC_EVENT_REGISTRY,
|
||||||
evaluateMechanicProgram,
|
evaluateMechanicProgram,
|
||||||
formatMechanicProgram,
|
formatMechanicProgram,
|
||||||
validateMechanicProgram,
|
validateMechanicProgram,
|
||||||
@@ -14,6 +15,16 @@ const custom = (encounterSeconds = 30, inputs: Record<string, number | boolean>
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('structured mechanic runtime', () => {
|
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', () => {
|
it('models Superior Firepower as same-target sliding hits and one shared expiring stack pool', () => {
|
||||||
const superiorFirepower: MechanicProgram = {
|
const superiorFirepower: MechanicProgram = {
|
||||||
id: 'superior-firepower',
|
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', () => {
|
it('models scaled OS Protocol restoration, an unscaled Health cost, and post-cost bonus damage', () => {
|
||||||
const upgradeScale = {
|
const upgradeScale = {
|
||||||
op: 'add' as const,
|
op: 'add' as const,
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ export type MechanicEventDefinition = {
|
|||||||
key: MechanicEventKind;
|
key: MechanicEventKind;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
fields?: readonly {
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
type: 'boolean' | 'number' | 'string';
|
||||||
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, MechanicEventDefinition>> = {
|
export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, MechanicEventDefinition>> = {
|
||||||
@@ -36,7 +41,11 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
|
|||||||
weapon_hit: {
|
weapon_hit: {
|
||||||
key: 'weapon_hit',
|
key: 'weapon_hit',
|
||||||
label: '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: {
|
skill_deployed: {
|
||||||
key: 'skill_deployed',
|
key: 'skill_deployed',
|
||||||
@@ -326,6 +335,7 @@ export type MechanicOutcomeEmission = {
|
|||||||
clauseId: string;
|
clauseId: string;
|
||||||
outcomeId: string;
|
outcomeId: string;
|
||||||
at: number;
|
at: number;
|
||||||
|
targetId: string | null;
|
||||||
phase: OutcomePhase;
|
phase: OutcomePhase;
|
||||||
kind: MechanicOutcome['kind'];
|
kind: MechanicOutcome['kind'];
|
||||||
scope: MechanicOutcome['scope'];
|
scope: MechanicOutcome['scope'];
|
||||||
@@ -401,6 +411,7 @@ type EvaluationContext = {
|
|||||||
type ContinuousDelivery = {
|
type ContinuousDelivery = {
|
||||||
clauseId: string;
|
clauseId: string;
|
||||||
outcomeId: string;
|
outcomeId: string;
|
||||||
|
targetId: string | null;
|
||||||
phase: OutcomePhase;
|
phase: OutcomePhase;
|
||||||
scope: MechanicOutcomeScope;
|
scope: MechanicOutcomeScope;
|
||||||
resource: MechanicResource;
|
resource: MechanicResource;
|
||||||
@@ -895,14 +906,22 @@ function emitOutcomes(
|
|||||||
continuousDeliveries?: ContinuousDelivery[]
|
continuousDeliveries?: ContinuousDelivery[]
|
||||||
): MechanicOutcomeEmission[] {
|
): MechanicOutcomeEmission[] {
|
||||||
const emissions: 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) => {
|
const eligible = (clause.outcomes ?? []).filter((outcome) => {
|
||||||
if (phase === 'before' && outcome.phase !== 'on_event') return false;
|
if (phase === 'before' && outcome.phase !== 'on_event') return false;
|
||||||
if (phase === 'after' && outcome.phase === 'on_event') return false;
|
if (phase === 'after' && outcome.phase === 'on_event') return false;
|
||||||
const current = outcomeState(outcome, context);
|
const { current, previous, effective } = stateValues(outcome);
|
||||||
const previous = outcome.statePool ? before.get(outcome.statePool) ?? current : current;
|
|
||||||
if (!phaseMatches(outcome.phase, previous, current)) return false;
|
if (!phaseMatches(outcome.phase, previous, current)) return false;
|
||||||
if (outcome.minStacks !== undefined && current < outcome.minStacks) return false;
|
if (outcome.minStacks !== undefined && effective < outcome.minStacks) return false;
|
||||||
if (outcome.maxStacks !== undefined && current > outcome.maxStacks) return false;
|
if (outcome.maxStacks !== undefined && effective > outcome.maxStacks) return false;
|
||||||
return true;
|
return true;
|
||||||
}).sort((left, right) => {
|
}).sort((left, right) => {
|
||||||
const priority = (outcome: MechanicOutcome) =>
|
const priority = (outcome: MechanicOutcome) =>
|
||||||
@@ -910,7 +929,7 @@ function emitOutcomes(
|
|||||||
return priority(left) - priority(right);
|
return priority(left) - priority(right);
|
||||||
});
|
});
|
||||||
for (const outcome of eligible) {
|
for (const outcome of eligible) {
|
||||||
const current = outcomeState(outcome, context);
|
const { effective: current } = stateValues(outcome);
|
||||||
const baseValue = numericValue(outcome.value, context);
|
const baseValue = numericValue(outcome.value, context);
|
||||||
const value = outcome.perStack ? baseValue * current : baseValue;
|
const value = outcome.perStack ? baseValue * current : baseValue;
|
||||||
const resolvedValue = resolveOutcomeValue(outcome, value, context);
|
const resolvedValue = resolveOutcomeValue(outcome, value, context);
|
||||||
@@ -925,6 +944,7 @@ function emitOutcomes(
|
|||||||
continuousDeliveries?.push({
|
continuousDeliveries?.push({
|
||||||
clauseId: clause.id,
|
clauseId: clause.id,
|
||||||
outcomeId: outcome.id,
|
outcomeId: outcome.id,
|
||||||
|
targetId: context.event.targetId ?? null,
|
||||||
phase: outcome.phase,
|
phase: outcome.phase,
|
||||||
scope: outcome.scope,
|
scope: outcome.scope,
|
||||||
resource: outcomeResource(outcome),
|
resource: outcomeResource(outcome),
|
||||||
@@ -957,6 +977,7 @@ function emitOutcomes(
|
|||||||
clauseId: clause.id,
|
clauseId: clause.id,
|
||||||
outcomeId: outcome.id,
|
outcomeId: outcome.id,
|
||||||
at,
|
at,
|
||||||
|
targetId: context.event.targetId ?? null,
|
||||||
phase: outcome.phase,
|
phase: outcome.phase,
|
||||||
kind: outcome.kind,
|
kind: outcome.kind,
|
||||||
scope: outcome.scope,
|
scope: outcome.scope,
|
||||||
@@ -1007,6 +1028,7 @@ function advanceContinuousDeliveries(
|
|||||||
clauseId: delivery.clauseId,
|
clauseId: delivery.clauseId,
|
||||||
outcomeId: delivery.outcomeId,
|
outcomeId: delivery.outcomeId,
|
||||||
at: through,
|
at: through,
|
||||||
|
targetId: delivery.targetId,
|
||||||
phase: delivery.phase,
|
phase: delivery.phase,
|
||||||
kind: 'restoration',
|
kind: 'restoration',
|
||||||
scope: delivery.scope,
|
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>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>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>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>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>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>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>
|
</dl>
|
||||||
<PseudoScript title="MECHANIC EXAMPLE" lines={example} compact />
|
<PseudoScript title="MECHANIC EXAMPLE" lines={example} compact />
|
||||||
<section class="calculation-language">
|
<section class="calculation-language">
|
||||||
|
|||||||
@@ -235,6 +235,7 @@
|
|||||||
| 'static'
|
| 'static'
|
||||||
| 'event'
|
| 'event'
|
||||||
| 'attribute'
|
| 'attribute'
|
||||||
|
| 'attribute_value'
|
||||||
| 'reducer'
|
| 'reducer'
|
||||||
| 'scenario'
|
| 'scenario'
|
||||||
| 'protocol_upgrades'
|
| 'protocol_upgrades'
|
||||||
@@ -243,6 +244,7 @@
|
|||||||
const valueModelOptions = $derived([
|
const valueModelOptions = $derived([
|
||||||
{ value: 'static', label: 'STATIC' },
|
{ value: 'static', label: 'STATIC' },
|
||||||
{ value: 'event', label: 'EVENT FIELD' },
|
{ value: 'event', label: 'EVENT FIELD' },
|
||||||
|
{ value: 'attribute_value', label: 'LIVE ATTRIBUTE' },
|
||||||
{ value: 'attribute', label: 'ATTRIBUTE ÷ 100' },
|
{ value: 'attribute', label: 'ATTRIBUTE ÷ 100' },
|
||||||
{ value: 'reducer', label: 'REDUCER' },
|
{ value: 'reducer', label: 'REDUCER' },
|
||||||
{ value: 'scenario', label: 'SCENARIO' },
|
{ 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 {
|
function outcomeValueModel(outcome: ConditionalMechanicOutcomeDraft): OutcomeValueModel {
|
||||||
if (valueModelOverrides[outcome.key]) return valueModelOverrides[outcome.key];
|
if (valueModelOverrides[outcome.key]) return valueModelOverrides[outcome.key];
|
||||||
const expression = Object.values(outcome.variants)[0]?.expression;
|
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 === 'event') return 'event';
|
||||||
if (expression.right.ref.source === 'scenario') return 'scenario';
|
if (expression.right.ref.source === 'scenario') return 'scenario';
|
||||||
if (expression.right.ref.source === 'reducer') return 'reducer';
|
if (expression.right.ref.source === 'reducer') return 'reducer';
|
||||||
|
if (expression.right.ref.source === 'attribute') return 'attribute_value';
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
'op' in expression.right
|
'op' in expression.right
|
||||||
@@ -356,6 +367,15 @@
|
|||||||
|
|
||||||
function outcomeAttributeReference(outcome: ConditionalMechanicOutcomeDraft) {
|
function outcomeAttributeReference(outcome: ConditionalMechanicOutcomeDraft) {
|
||||||
const expression = Object.values(outcome.variants)[0]?.expression;
|
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 (
|
if (
|
||||||
expression
|
expression
|
||||||
&& typeof expression === 'object'
|
&& typeof expression === 'object'
|
||||||
@@ -380,6 +400,9 @@
|
|||||||
if (model === 'attribute') return reference
|
if (model === 'attribute') return reference
|
||||||
? attributePercentExpression(value, reference)
|
? attributePercentExpression(value, reference)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
if (model === 'attribute_value') return reference
|
||||||
|
? liveAttributeExpression(value, reference)
|
||||||
|
: undefined;
|
||||||
if (model === 'scenario') return reference
|
if (model === 'scenario') return reference
|
||||||
? referenceExpression('scenario', reference, value)
|
? referenceExpression('scenario', reference, value)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -398,7 +421,9 @@
|
|||||||
) {
|
) {
|
||||||
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
||||||
const nextOverrides = { ...valueModelOverrides };
|
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];
|
else delete nextOverrides[outcome.key];
|
||||||
valueModelOverrides = nextOverrides;
|
valueModelOverrides = nextOverrides;
|
||||||
const variants = Object.fromEntries(Object.entries(outcome.variants).map(([key, variant]) => {
|
const variants = Object.fromEntries(Object.entries(outcome.variants).map(([key, variant]) => {
|
||||||
@@ -442,6 +467,17 @@
|
|||||||
variants: { base: { value, expression } }
|
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') {
|
if (model === 'reducer') {
|
||||||
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
|
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
|
||||||
}
|
}
|
||||||
@@ -452,8 +488,11 @@
|
|||||||
function addMutation(clauseIndex: number) {
|
function addMutation(clauseIndex: number) {
|
||||||
const pool = rule.pools[0]?.key;
|
const pool = rule.pools[0]?.key;
|
||||||
if (!pool) return;
|
if (!pool) return;
|
||||||
|
const entity = rule.pools[0]?.scope === 'target'
|
||||||
|
? { kind: 'event_target' as const }
|
||||||
|
: undefined;
|
||||||
updateClause(clauseIndex, {
|
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 mutations = [...rule.clauses[clauseIndex].mutations];
|
||||||
const current = mutations[mutationIndex];
|
const current = mutations[mutationIndex];
|
||||||
const nextPool = pool ?? ('pool' in current ? current.pool : rule.pools[0]?.key ?? '');
|
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'
|
mutations[mutationIndex] = kind === 'clear'
|
||||||
? { kind, pool: nextPool }
|
? { kind, pool: nextPool, entity }
|
||||||
: { kind, pool: nextPool, value: value ?? ('value' in current && typeof current.value === 'number' ? current.value : 1) };
|
: { 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 });
|
updateClause(clauseIndex, { mutations });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -680,6 +735,15 @@
|
|||||||
{#if mutation.kind !== 'clear'}
|
{#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>
|
<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}
|
||||||
|
{#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>
|
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -747,14 +811,14 @@
|
|||||||
columns={Math.min(4,rule.scenarioInputs.length)}
|
columns={Math.min(4,rule.scenarioInputs.length)}
|
||||||
onchange={(values) => setOutcomeValueModel(clauseIndex,outcomeIndex,'scenario',values[0])}
|
onchange={(values) => setOutcomeValueModel(clauseIndex,outcomeIndex,'scenario',values[0])}
|
||||||
/>
|
/>
|
||||||
{:else if outcomeValueModel(outcome) === 'attribute'}
|
{:else if outcomeValueModel(outcome) === 'attribute' || outcomeValueModel(outcome) === 'attribute_value'}
|
||||||
<SearchableAssignment
|
<SearchableAssignment
|
||||||
items={attributes}
|
items={attributes}
|
||||||
mode="single"
|
mode="single"
|
||||||
compact
|
compact
|
||||||
subjectLabel="VALUE ATTRIBUTE"
|
subjectLabel="VALUE ATTRIBUTE"
|
||||||
value={outcomeAttributeReference(outcome)}
|
value={outcomeAttributeReference(outcome)}
|
||||||
onvaluechange={(value) => setOutcomeValueModel(clauseIndex,outcomeIndex,'attribute',value)}
|
onvaluechange={(value) => setOutcomeValueModel(clauseIndex,outcomeIndex,outcomeValueModel(outcome),value)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{#if rule.pools.length}
|
{#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)}
|
.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)}
|
.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-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-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}
|
.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}
|
.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: []
|
recipeKeys: []
|
||||||
}));
|
}));
|
||||||
|
|
||||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(4);
|
expect(AI_MECHANIC_PROMPT_VERSION).toBe(5);
|
||||||
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
|
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
|
||||||
expect.arrayContaining(['delivery', 'durationSeconds'])
|
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.stringContaining('Do not replace continuous delivery with a periodic reducer')
|
||||||
]));
|
]));
|
||||||
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
||||||
'Prompt contract version: 4'
|
'Prompt contract version: 5'
|
||||||
);
|
);
|
||||||
|
|
||||||
const advertised = structuredClone(
|
const advertised = structuredClone(
|
||||||
@@ -156,4 +156,89 @@ describe('AI mechanic authoring prompt', () => {
|
|||||||
});
|
});
|
||||||
expect(parsed.ok).toBe(true);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { AiMechanicAuthoringRequest } from '$lib/ai/mechanic-authoring';
|
|||||||
import { CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES } from '$lib/calculations/mechanic-editor';
|
import { CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES } from '$lib/calculations/mechanic-editor';
|
||||||
import type { AiMechanicAttribute } from './mechanic-authoring-context';
|
import type { AiMechanicAttribute } from './mechanic-authoring-context';
|
||||||
|
|
||||||
export const AI_MECHANIC_PROMPT_VERSION = 4;
|
export const AI_MECHANIC_PROMPT_VERSION = 5;
|
||||||
|
|
||||||
const mechanicsGrammar = {
|
const mechanicsGrammar = {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -19,7 +19,9 @@ const mechanicsGrammar = {
|
|||||||
scope: ['agent', 'target'],
|
scope: ['agent', 'target'],
|
||||||
optional: ['min', 'max', 'bestCaseValue', 'expiresAfterSeconds'],
|
optional: ['min', 'max', 'bestCaseValue', 'expiresAfterSeconds'],
|
||||||
expirationSemantics:
|
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: {
|
clause: {
|
||||||
required: ['key', 'event', 'if', 'reducer', 'mutations', 'outcomes'],
|
required: ['key', 'event', 'if', 'reducer', 'mutations', 'outcomes'],
|
||||||
@@ -94,6 +96,12 @@ const mechanicsGrammar = {
|
|||||||
mutationShapes: [
|
mutationShapes: [
|
||||||
{ kind: 'none' },
|
{ kind: 'none' },
|
||||||
{ kind: 'add', pool: 'stacks', value: 1 },
|
{ kind: 'add', pool: 'stacks', value: 1 },
|
||||||
|
{
|
||||||
|
kind: 'add',
|
||||||
|
pool: 'target-stacks',
|
||||||
|
entity: { kind: 'event_target' },
|
||||||
|
value: 1
|
||||||
|
},
|
||||||
{ kind: 'set', pool: 'active', value: 1 },
|
{ kind: 'set', pool: 'active', value: 1 },
|
||||||
{ kind: 'consume', pool: 'stacks', value: 1 },
|
{ kind: 'consume', pool: 'stacks', value: 1 },
|
||||||
{ kind: 'derive', pool: 'nearby-enemies', value: { ref: { source: 'reducer' } } },
|
{ 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: {
|
variantValue: {
|
||||||
required: ['value'],
|
required: ['value'],
|
||||||
optional: ['expression']
|
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%.',
|
'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.',
|
'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.',
|
'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.',
|
'OS Protocol upgrade scaling may use equipment.protocolTalentUpgrades only when allowProtocolUpgradeScaling is true.',
|
||||||
'Assigned-ability scope may be used only when allowAssignedAbility 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.',
|
'If cadence is not stated, ask rather than assuming a one-second interval.',
|
||||||
|
|||||||
@@ -182,14 +182,27 @@ describe('conditional mechanic persistence adapter', () => {
|
|||||||
version: 1,
|
version: 1,
|
||||||
simulationReadiness: 'ready',
|
simulationReadiness: 'ready',
|
||||||
scenarioInputs: [],
|
scenarioInputs: [],
|
||||||
pools: [],
|
pools: [{
|
||||||
|
key: 'marked-target',
|
||||||
|
label: 'Marked Target',
|
||||||
|
stateKind: 'target_mark',
|
||||||
|
scope: 'target',
|
||||||
|
initial: 0,
|
||||||
|
min: 0,
|
||||||
|
max: 1
|
||||||
|
}],
|
||||||
clauses: [{
|
clauses: [{
|
||||||
key: 'weapon-kill',
|
key: 'weapon-kill',
|
||||||
label: 'Weapon kill',
|
label: 'Weapon kill',
|
||||||
event: 'enemy_killed',
|
event: 'enemy_killed',
|
||||||
if: { kind: 'true' },
|
if: { kind: 'true' },
|
||||||
reducer: { kind: 'none' },
|
reducer: { kind: 'none' },
|
||||||
mutations: [],
|
mutations: [{
|
||||||
|
kind: 'set',
|
||||||
|
pool: 'marked-target',
|
||||||
|
entity: { kind: 'event_target' },
|
||||||
|
value: 1
|
||||||
|
}],
|
||||||
outcomes: [{
|
outcomes: [{
|
||||||
key: 'restore-health',
|
key: 'restore-health',
|
||||||
label: 'Restore Health',
|
label: 'Restore Health',
|
||||||
@@ -204,6 +217,51 @@ describe('conditional mechanic persistence adapter', () => {
|
|||||||
I: { value: 240 },
|
I: { value: 240 },
|
||||||
II: { value: 336 }
|
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();
|
)).all();
|
||||||
expect(saved).toEqual([
|
expect(saved).toEqual([
|
||||||
{ delivery_mode: 'continuous', delivery_duration_seconds: 3 },
|
{ 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(
|
const loaded = await loadConditionalRuleDraft(
|
||||||
database,
|
database,
|
||||||
@@ -229,5 +289,39 @@ describe('conditional mechanic persistence adapter', () => {
|
|||||||
II: { value: 336 }
|
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 = [
|
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: '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: 'CONSUME value FROM pool', use: 'Spends available state without falling below the pool minimum.' },
|
||||||
{ syntax: 'CLEAR pool', use: 'Resets the selected state pool.' },
|
{ 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: 'ON_EVENT', use: 'Applies against combat state as the event arrives.' },
|
||||||
{ syntax: 'AFTER_EVENT', use: 'Applies after branch mutations and resource costs.' },
|
{ 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_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.' }
|
{ 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',
|
'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'
|
'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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -210,6 +226,13 @@
|
|||||||
<article>
|
<article>
|
||||||
<div><strong>{event.label}</strong><code>{event.key}</code></div>
|
<div><strong>{event.label}</strong><code>{event.key}</code></div>
|
||||||
<p>{event.description}</p>
|
<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>
|
</article>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -318,7 +341,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<aside class="contract">
|
<aside class="contract">
|
||||||
<strong>EXPIRY CONTRACT</strong>
|
<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>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -376,6 +399,10 @@
|
|||||||
<section id="examples" class="reference-section examples-section">
|
<section id="examples" class="reference-section examples-section">
|
||||||
<header><div><span>10</span><h3>WORKED MECHANICS</h3></div></header>
|
<header><div><span>10</span><h3>WORKED MECHANICS</h3></div></header>
|
||||||
<div class="example-intro">
|
<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>
|
<div>
|
||||||
<h4>SUPERIOR FIREPOWER</h4>
|
<h4>SUPERIOR FIREPOWER</h4>
|
||||||
<p>Nine hits on one target add a Relentless stack. Each write refreshes the ten-second pool timer.</p>
|
<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>
|
</div>
|
||||||
<div class="example-scripts">
|
<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="SUPERIOR FIREPOWER · GENERATED EXAMPLE" lines={superiorFirepower} readiness="READY" />
|
||||||
<PseudoScript title="HAPPY SURVIVAL · GENERATED EXAMPLE" lines={happySurvival} readiness="REQUIRES ASSUMPTIONS" />
|
<PseudoScript title="HAPPY SURVIVAL · GENERATED EXAMPLE" lines={happySurvival} readiness="REQUIRES ASSUMPTIONS" />
|
||||||
</div>
|
</div>
|
||||||
@@ -652,6 +680,25 @@
|
|||||||
line-height: 1.45;
|
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 {
|
.action-registry {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@@ -937,7 +984,7 @@
|
|||||||
|
|
||||||
.example-scripts {
|
.example-scripts {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 0 12px 12px;
|
padding: 0 12px 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,28 +219,33 @@ export const actions: Actions = {
|
|||||||
valueII: effect.variants.II.value
|
valueII: effect.variants.II.value
|
||||||
}));
|
}));
|
||||||
const validationErrors: TalentValidationError[] = [];
|
const validationErrors: TalentValidationError[] = [];
|
||||||
|
const requiresCompleteMechanic = conditionalRule.simulationReadiness !== 'captured_only';
|
||||||
if (name.length < 2 || !['Weapon Talent 1', 'Weapon Talent 2', 'Backpack', 'Body Armor'].includes(category)) {
|
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.' });
|
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 (!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 (requiresCompleteMechanic && !conditionalRule.clauses.length) {
|
||||||
if (!sharedEffects.length) validationErrors.push({ area: 'outcomes', message: 'Add at least one numbered value outcome.' });
|
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))) {
|
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.' });
|
validationErrors.push({ area: 'outcomes', message: 'Every numbered outcome needs valid Tier I and Tier II values.' });
|
||||||
}
|
}
|
||||||
const missingSlots = missingTalentDescriptionSlots(descriptionTemplate, descriptionOutcomes);
|
const missingSlots = missingTalentDescriptionSlots(descriptionTemplate, descriptionOutcomes);
|
||||||
if (missingSlots.length) {
|
if (requiresCompleteMechanic && missingSlots.length) {
|
||||||
validationErrors.push({
|
validationErrors.push({
|
||||||
area: 'description',
|
area: 'description',
|
||||||
message: `Description placeholder${missingSlots.length === 1 ? '' : 's'} ${missingSlots.map((slot) => `{${slot}}`).join(', ')} must reference numbered outcomes.`
|
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.' });
|
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: 'description', message: 'The description uses {duration}, but no duration is defined.' });
|
||||||
validationErrors.push({ area: 'mechanic', message: 'Define a duration for the {duration} description placeholder.' });
|
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: '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.' });
|
validationErrors.push({ area: 'mechanic', message: 'Define a pool maximum for the {max_stacks} description placeholder.' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,28 +42,9 @@
|
|||||||
simulationReadiness: 'captured_only',
|
simulationReadiness: 'captured_only',
|
||||||
scenarioInputs: [],
|
scenarioInputs: [],
|
||||||
pools: [],
|
pools: [],
|
||||||
clauses: [{
|
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 } }
|
|
||||||
}]
|
|
||||||
}]
|
|
||||||
});
|
});
|
||||||
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 editTalent = (row: any) => {
|
||||||
const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', effects: [] };
|
const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', effects: [] };
|
||||||
const one = tier('I'), two = tier('II');
|
const one = tier('I'), two = tier('II');
|
||||||
@@ -72,7 +53,8 @@
|
|||||||
descriptionTemplate: row.descriptionTemplate || one.description,
|
descriptionTemplate: row.descriptionTemplate || one.description,
|
||||||
tierIDescription: one.description,
|
tierIDescription: one.description,
|
||||||
tierIIDescription: two.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);
|
const configured = (tiers: Array<{ tier: unknown }>, name: string) => tiers.some((item) => item.tier === name);
|
||||||
@@ -105,6 +87,7 @@
|
|||||||
if (form.talentMode === 'add') iconResetToken += 1;
|
if (form.talentMode === 'add') iconResetToken += 1;
|
||||||
talent = form.talentMode === 'add' ? blankTalent() : null;
|
talent = form.talentMode === 'add' ? blankTalent() : null;
|
||||||
} else if (talentErrors.length) {
|
} else if (talentErrors.length) {
|
||||||
|
if (hasErrors(['mechanic', 'outcomes'])) talent.modelingOpen = true;
|
||||||
void tick().then(() => validationSummary?.focus());
|
void tick().then(() => validationSummary?.focus());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -143,7 +126,12 @@
|
|||||||
</div>
|
</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>
|
<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 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
|
<MechanicProgramEditor
|
||||||
attributes={data.stats}
|
attributes={data.stats}
|
||||||
valueColumns={[{key:'tierI',variantKey:'I',label:'TIER I'},{key:'tierII',variantKey:'II',label:'TIER II'}]}
|
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}
|
{#each [...errorsFor('mechanic'), ...errorsFor('outcomes')] as error}<small class="field-error mechanic-field-error">{error.message}</small>{/each}
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => talent = null}>CANCEL</button><button class="primary">SAVE TALENT →</button></div>
|
<div class="form-actions"><button type="button" class="secondary" onclick={() => talent = null}>CANCEL</button><button class="primary">SAVE TALENT →</button></div>
|
||||||
</PendingForm>
|
</PendingForm>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user