From 0b3be3587bb9c34aab49799abeffa31583e110de Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 27 Jul 2026 22:20:52 -0400 Subject: [PATCH] Support target-scoped trauma mechanics --- docs/DESIGN_SYSTEM.md | 29 +++- docs/architecture.md | 24 ++- src/app.css | 16 ++ src/lib/calculations/conditional.test.ts | 14 +- src/lib/calculations/conditional.ts | 12 ++ src/lib/calculations/mechanic-editor.test.ts | 127 ++++++++++++++ .../calculations/mechanic-persistence.test.ts | 107 ++++++++++++ src/lib/calculations/mechanic-persistence.ts | 57 +++++-- src/lib/calculations/mechanic-recipes.test.ts | 22 +++ src/lib/calculations/mechanic-recipes.ts | 18 +- src/lib/calculations/mechanic.test.ts | 156 ++++++++++++++++++ src/lib/calculations/mechanic.ts | 34 +++- .../components/MechanicLanguageHelp.svelte | 3 +- .../isac/MechanicProgramEditor.svelte | 78 ++++++++- .../ai/mechanic-authoring-prompt.test.ts | 89 +++++++++- .../server/ai/mechanic-authoring-prompt.ts | 108 +++++++++++- src/lib/server/conditional-effects.test.ts | 100 ++++++++++- .../isac/mechanic-reference/+page.svelte | 55 +++++- src/routes/isac/talents/+page.server.ts | 15 +- src/routes/isac/talents/+page.svelte | 61 +++---- 20 files changed, 1032 insertions(+), 93 deletions(-) diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index d9e1ccb..62aa092 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -2005,20 +2005,33 @@ branches, ordered outcomes, and the generated read-only script. Use connected panels and restrained borders to preserve the hierarchy; do not present the typed graph as an undifferentiated form. +Talent editors place that surface inside the shared `modeling-disclosure` +pattern. New `CAPTURED ONLY` Talents start with an empty program and a collapsed +`COMBAT MODELING` summary so authors can record identity, image, and source +description first. Existing modeled Talents open the disclosure when edited. +Mechanic validation errors open it and keep the relevant error text visible. +The summary reports `CAPTURE ONLY · MODEL LATER` or the configured branch count +in text. Its native disclosure semantics preserve keyboard and assistive +technology support; mobile targets remain at least 44 pixels tall. + The direct-event picker reads the 19 normalized events from the code-defined event registry. Use searchable single-assignment because the vocabulary exceeds the compact segmented-choice limit. Each branch presents its event, match policy and parameters, optional cooldown, state mutations, and outcomes. Recipe-generated predicates and weights remain visible in the generated script. State rows expose label, agent or target scope, initial value, bounds, -best-case value, and optional expiry. Outcome rows expose kind, target scope, -Attribute or resource when applicable, value basis, state scaling, phase, and -every source-specific value column. The Value Model control supports static -values, live Attribute percentages, event fields, reducer output, source-defined -scenario controls, and OS Protocol upgrade scaling where the source permits it. -Selecting a live Attribute uses the shared searchable single-assignment pattern -and displays the Attribute's unit. Label the percentage-normalized model -`ATTRIBUTE ÷ 100` so authors can see the transform applied to the stored value. +best-case value, and optional expiry. A state change that writes a target pool +uses a `STATE OWNER` segmented control with `EVENT TARGET` and `EVENT ACTOR`; +the selected owner remains visible in the generated script. Outcome rows expose +kind, target scope, Attribute or resource when applicable, value basis, state +scaling, phase, and every source-specific value column. The Value Model control +supports static values, live Attribute totals, percentage-normalized Attribute +values, event fields, reducer output, source-defined scenario controls, and OS +Protocol upgrade scaling where the source permits it. Selecting either +Attribute model uses the shared searchable single-assignment pattern and +displays the Attribute's unit. Label the direct model `LIVE ATTRIBUTE`. Label +the percentage-normalized model `ATTRIBUTE ÷ 100` so authors can see the +transform applied to the stored value. Restoration outcomes also expose an `INSTANT` or `CONTINUOUS` segmented choice. Continuous delivery reveals one required duration field and describes the configured value as the total diff --git a/docs/architecture.md b/docs/architecture.md index 8359fcc..6f87573 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -398,7 +398,10 @@ normalized events are `shot_fired`, `weapon_hit`, `skill_deployed`, `movement_snapshot`, `proximity_snapshot`, `firing_started`, `firing_stopped`, `reload_started`, `reload_completed`, `weapon_swapped`, and `tick`. Event details such as target, distance, critical state, source, movement, and nearby -counts travel as typed event values. +counts travel as typed event values. The event registry publishes authoritative +field metadata where branches need a bounded contract. For example, +`weapon_hit.values.headshot` is a boolean that distinguishes headshots from +body shots. The optional Action Family catalog provides authoring recipes for common mechanics. Applying a recipe copies its typed scenario inputs, state pools, and @@ -408,6 +411,12 @@ dependency. Adding another recipe appends branches. Replacing a program with a recipe requires confirmation because replacement discards the current branch graph. +A Talent may save an empty `captured_only` program while an author records its +name, category, image, and source description. The registry marks that Talent +`CONFIG REQUIRED`, and calculations exclude it. Changing readiness to `ready` +or `requires_assumptions` restores strict branch, numbered-outcome, and +description-placeholder validation. + The program graph uses three child tables: - `conditional_effect_state_pools` defines agent- or target-scoped flags, @@ -418,14 +427,16 @@ The program graph uses three child tables: state mutations, and optional recipe provenance. Reducers convert event streams into counts, distinct or consecutive matches, sliding windows, durations, intervals, or derived values. Mutations can set, add, consume, - clear, derive, or transfer state. + clear, derive, or transfer state. A mutation may bind a target-scoped pool to + the current event target, event actor, or another typed entity selector. Each + target owns an independent value and expiry timer. - `conditional_effect_outcomes` applies Attribute modifiers, direct damage, restoration, resource costs, or markers. Each outcome names its target scope, value basis, optional resource or Attribute, combat timing, optional state band, and phase: `on_event`, `after_event`, `on_enter`, `on_exit`, or `while_active`. One typed value or expression exists for every variant required by the owning source. The common value models are static values, - live Attribute percentages, event-field values, reducer output, + live Attribute values, percentage-normalized Attribute values, event-field values, reducer output, source-defined scenario values, and OS Protocol upgrade scaling. Restoration outcomes may deliver their total immediately or continuously over an explicit duration. @@ -436,6 +447,13 @@ convert event streams into activations or derived values. The runtime applies state mutations, enforces pool bounds and expiry, emits outcomes in phase order, and records telemetry for each activation. +Each write to an expiring pool refreshes its timer. When a pool expires, +`on_exit` outcomes evaluate stack thresholds and per-stack expressions against +the final value before reset. The runtime records the bound target on emitted +outcomes. A one-second target debuff with a one-second damage cadence can +therefore resolve once at its refreshed shared expiry without inventing +intermediate ticks. + Live Attribute references use a stable Attribute ID and read the authoritative loadout total that the builder captures for the evaluation pass. Percentage Attributes retain their displayed units, so an expression that applies a diff --git a/src/app.css b/src/app.css index 327771e..9667f2e 100644 --- a/src/app.css +++ b/src/app.css @@ -2393,3 +2393,19 @@ .field-error{display:block;margin:5px 0 0;color:var(--shd-danger);font-size:var(--shd-type-telemetry);line-height:1.4} .mechanic-field-error{padding-left:10px;border-left:2px solid var(--shd-danger)} @media(max-width:560px){.validation-summary a{grid-template-columns:1fr;gap:1px}} + +/* Collapsible modeled-data workspace for capture-first ISAC editors. */ +.modeling-disclosure{margin-top:10px;border:1px solid var(--shd-border);background:var(--shd-surface)} +.modeling-disclosure>summary{min-height:52px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 11px;list-style:none;background:var(--shd-surface-raised);cursor:pointer} +.modeling-disclosure>summary::-webkit-details-marker{display:none} +.modeling-disclosure>summary span{display:grid;gap:3px} +.modeling-disclosure>summary b,.modeling-disclosure>summary strong{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.9px} +.modeling-disclosure>summary small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.5px} +.modeling-disclosure>summary strong{color:var(--shd-orange)} +.modeling-disclosure>summary:hover,.modeling-disclosure>summary:focus-visible{background:var(--shd-surface-active);box-shadow:inset 2px 0 var(--shd-orange);outline:none} +.modeling-disclosure[open]>summary{border-bottom:1px solid var(--shd-border);box-shadow:inset 2px 0 var(--shd-orange)} +.modeling-disclosure.validation-area-error{border-color:var(--shd-danger)} +.modeling-disclosure.validation-area-error>summary{box-shadow:inset 2px 0 var(--shd-danger)} +.modeling-content{padding:10px} +@media(max-width:560px){.modeling-disclosure>summary{min-height:56px}.modeling-content{padding:7px}} +@media(prefers-reduced-motion:reduce){.modeling-disclosure>summary{transition:none}} diff --git a/src/lib/calculations/conditional.test.ts b/src/lib/calculations/conditional.test.ts index da9251f..9d43e46 100644 --- a/src/lib/calculations/conditional.test.ts +++ b/src/lib/calculations/conditional.test.ts @@ -43,7 +43,7 @@ const shot = ( describe('trigger action registry', () => { it('owns every action and validates discriminated parameters', () => { - expect(Object.keys(TRIGGER_ACTION_REGISTRY)).toHaveLength(15); + expect(Object.keys(TRIGGER_ACTION_REGISTRY)).toHaveLength(16); expect(validateTriggerConfiguration('same_target_hits_within_window', { hitsRequired: 9, windowSeconds: 3 @@ -197,6 +197,18 @@ describe('evaluateConditionalProgram', () => { }); }); + it('treats an explicit non-headshot hit as a body shot', () => { + const result = evaluateConditionalProgram(program({ + action: 'body_shot', + params: {} + }), [ + shot(1, 'alpha', true, false), + shot(2, 'alpha', true, true), + shot(3, 'alpha', false, false) + ], { mode: 'encounter', encounterSeconds: 5 }); + expect(result.activations.map((activation) => activation.at)).toEqual([1]); + }); + it('refreshes one shared duration and clears every stack on expiry', () => { const result = evaluateConditionalProgram(program({ action: 'damage_taken', diff --git a/src/lib/calculations/conditional.ts b/src/lib/calculations/conditional.ts index 4eda9ab..f042661 100644 --- a/src/lib/calculations/conditional.ts +++ b/src/lib/calculations/conditional.ts @@ -6,6 +6,7 @@ export const TRIGGER_ACTION_KEYS = [ 'while_firing', 'health_threshold_bands', 'headshot', + 'body_shot', 'headshot_kill', 'weapon_kill', 'target_hit_within_distance', @@ -52,6 +53,7 @@ export type TriggerParamsByAction = { while_firing: EmptyTriggerParams; health_threshold_bands: HealthThresholdParams; headshot: EmptyTriggerParams; + body_shot: EmptyTriggerParams; headshot_kill: EmptyTriggerParams; weapon_kill: EmptyTriggerParams; target_hit_within_distance: DistanceParams; @@ -374,6 +376,12 @@ export const TRIGGER_ACTION_REGISTRY: Readonly 'Land a headshot.' }, + body_shot: { + key: 'body_shot', + label: 'Body shot', + validate: validateEmpty, + summarize: () => 'Land a non-headshot weapon hit.' + }, headshot_kill: { key: 'headshot_kill', label: 'Headshot kill', @@ -611,6 +619,10 @@ function eventCandidates( return events .filter((event): event is ShotEvent => event.kind === 'shot' && event.hit && event.headshot === true) .map((event) => ({ at: event.at, outcomeKey: null })); + case 'body_shot': + return events + .filter((event): event is ShotEvent => event.kind === 'shot' && event.hit && event.headshot === false) + .map((event) => ({ at: event.at, outcomeKey: null })); case 'headshot_kill': return events .filter((event): event is EnemyKilledEvent => event.kind === 'enemy_killed' && event.headshot === true) diff --git a/src/lib/calculations/mechanic-editor.test.ts b/src/lib/calculations/mechanic-editor.test.ts index 3574704..d0497b0 100644 --- a/src/lib/calculations/mechanic-editor.test.ts +++ b/src/lib/calculations/mechanic-editor.test.ts @@ -54,6 +54,41 @@ const draft = (): ConditionalMechanicDraftV1 => ({ }); describe('conditional mechanic authoring payload', () => { + it('accepts an empty captured-only program for deferred modeling', () => { + const partial: ConditionalMechanicDraftV1 = { + version: 1, + simulationReadiness: 'captured_only', + scenarioInputs: [], + pools: [], + clauses: [] + }; + const result = parseConditionalMechanicDraft(partial, { + variantKeys: ['I', 'II'] + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.draft).toEqual(partial); + expect(result.value.programs.I.clauses).toEqual([]); + expect(result.value.programs.II.clauses).toEqual([]); + }); + + it('requires a branch after a partial program is marked calculation-ready', () => { + const partial: ConditionalMechanicDraftV1 = { + version: 1, + simulationReadiness: 'ready', + scenarioInputs: [], + pools: [], + clauses: [] + }; + const result = parseConditionalMechanicDraft(partial, { + variantKeys: ['I', 'II'] + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain('A simulation-ready mechanic requires at least one clause.'); + } + }); + it('builds one validated runtime program per exact source variant', () => { const result = parseConditionalMechanicDraft(draft(), { variantKeys: ['I', 'II'], @@ -263,6 +298,98 @@ describe('conditional mechanic authoring payload', () => { } }); + it('preserves target entity mutations and state-scaled exit outcomes', () => { + const targeted: ConditionalMechanicDraftV1 = { + version: 1, + simulationReadiness: 'ready', + scenarioInputs: [], + pools: [{ + key: 'marked-target', + label: 'Marked Target', + stateKind: 'target_mark', + scope: 'target', + initial: 0, + min: 0, + max: 1 + }], + clauses: [{ + key: 'detonate-mark', + event: 'enemy_killed', + reducer: { kind: 'none' }, + mutations: [{ + kind: 'set', + pool: 'marked-target', + entity: { kind: 'event_target' }, + value: 1 + }], + outcomes: [{ + key: 'target-damage', + label: 'Target Damage', + phase: 'on_exit', + kind: 'direct_damage', + scope: 'target', + basis: 'flat', + statePool: 'marked-target', + perStack: true, + variants: { + base: { + value: 240, + expression: { + op: 'multiply', + left: 240, + right: { + op: 'divide', + left: { + ref: { + source: 'attribute', + attributeId: 'firepower-multiplier' + } + }, + right: 100 + } + } + } + } + }] + }] + }; + const result = parseConditionalMechanicDraft(targeted, { + variantKeys: ['base'], + allowedAttributeIds: new Set(['firepower-multiplier']) + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.draft).toMatchObject(targeted); + expect(result.value.programs.base).toMatchObject({ + pools: [{ key: 'marked-target', scope: 'target' }], + clauses: [{ + mutations: [{ + kind: 'set', + pool: 'marked-target', + entity: { kind: 'event_target' }, + value: 1 + }], + outcomes: [{ + phase: 'on_exit', + kind: 'direct_damage', + statePool: 'marked-target', + perStack: true, + value: { + right: { + left: { + ref: { + source: 'attribute', + attributeId: 'firepower-multiplier' + } + } + } + } + }] + }] + }); + }); + it('rejects outcome fields that are absent from the canonical capability manifest', () => { const unsupported = draft(); (unsupported.clauses[0].outcomes[0] as unknown as Record).cadence = diff --git a/src/lib/calculations/mechanic-persistence.test.ts b/src/lib/calculations/mechanic-persistence.test.ts index d00453e..eca1646 100644 --- a/src/lib/calculations/mechanic-persistence.test.ts +++ b/src/lib/calculations/mechanic-persistence.test.ts @@ -215,6 +215,113 @@ describe('conditional-effect persistence adapter', () => { expect(result.errors).toContain('Outcome 1 continuous delivery requires a positive duration.'); }); + it('preserves target pool mutations, exit outcomes, and live Attribute references', () => { + const firepowerMultiplierId = '82a95611-283b-4fd8-a6ed-3567a316f36e'; + const result = adaptConditionalEffectProgram({ + rule: rule(), + statePools: [pool({ + state_key: 'marked-target', + label: 'Marked Target', + state_kind: 'target_mark', + state_scope: 'target', + maximum_value: 1, + best_case_value: 1 + })], + clauses: [clause({ + trigger_id: null, + event_kind: 'enemy_killed', + parameters_json: '{}', + mutations_json: JSON.stringify([{ + kind: 'set', + pool: 'marked-target', + entity: { kind: 'event_target' }, + value: 1 + }]) + })], + outcomes: [outcome({ + effect_key: 'marked-target-detonation', + application_kind: 'direct_damage', + effect_scope: 'target', + stat_attribute_id: null, + value: 240, + value_expression_json: JSON.stringify({ + op: 'multiply', + left: 240, + right: { + op: 'divide', + left: { + ref: { + source: 'attribute', + attributeId: firepowerMultiplierId + } + }, + right: 100 + } + }), + value_scaling: 'per_state_unit', + damage_basis: 'flat', + application_phase: 'on_exit', + min_state_value: null + })], + triggers: [] + }, { name: 'Target Detonation', variantKey: 'I' }); + + expect(result.errors).toEqual([]); + expect(result.program?.pools?.[0]).toMatchObject({ + key: 'marked-target', + scope: 'target' + }); + expect(result.program?.clauses[0]).toMatchObject({ + mutations: [{ + kind: 'set', + pool: 'marked-target', + entity: { kind: 'event_target' }, + value: 1 + }], + outcomes: [{ + phase: 'on_exit', + kind: 'direct_damage', + scope: 'target', + statePool: 'marked-target', + perStack: true, + value: { + op: 'multiply', + left: 240, + right: { + op: 'divide', + left: { + ref: { + source: 'attribute', + attributeId: firepowerMultiplierId + } + }, + right: 100 + } + } + }] + }); + }); + + it('rejects malformed mutation entity selectors from persisted JSON', () => { + const result = adaptConditionalEffectProgram({ + rule: rule(), + statePools: [pool({ state_scope: 'target' })], + clauses: [clause({ + mutations_json: JSON.stringify([{ + kind: 'set', + pool: 'effect', + entity: { kind: 'event_field', path: '' }, + value: 1 + }]) + })], + outcomes: [outcome()], + triggers: [{ id: 'trigger-1', action_key: 'same_target_hits_within_window' }] + }, { variantKey: 'I' }); + + expect(result.status).toBe('INVALID'); + expect(result.errors).toContain('Clause 1 mutations must be an array of valid state mutations.'); + }); + it('adapts the Happy Survival branches, explicit cadence, health timing, and upgrade references', () => { const upgradeScale = { op: 'add', diff --git a/src/lib/calculations/mechanic-persistence.ts b/src/lib/calculations/mechanic-persistence.ts index dffd4c3..63112ad 100644 --- a/src/lib/calculations/mechanic-persistence.ts +++ b/src/lib/calculations/mechanic-persistence.ts @@ -174,20 +174,43 @@ function requiredNumber(value: DatabaseValue, path: string, errors: string[]) { return result ?? 0; } +function isEntitySelector(value: unknown) { + if (!object(value) || typeof value.kind !== 'string') return false; + if (value.kind === 'agent' || value.kind === 'event_actor' || value.kind === 'event_target') { + return true; + } + return value.kind === 'event_field' + && typeof value.path === 'string' + && Boolean(value.path.trim()); +} + +function isValueReference(value: unknown) { + if (!object(value) || typeof value.source !== 'string') return false; + if (value.source === 'event' || value.source === 'equipment') { + return typeof value.path === 'string' && Boolean(value.path.trim()); + } + if (value.source === 'combat') { + return typeof value.path === 'string' + && Boolean(value.path.trim()) + && (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current'); + } + if (value.source === 'attribute') { + return typeof value.attributeId === 'string' && Boolean(value.attributeId.trim()); + } + if (value.source === 'scenario') { + return typeof value.key === 'string' && Boolean(value.key.trim()); + } + if (value.source === 'reducer') return true; + return value.source === 'pool' + && typeof value.pool === 'string' + && Boolean(value.pool.trim()) + && (value.entity === undefined || isEntitySelector(value.entity)); +} + function isNumericExpression(value: unknown): value is NumericExpression { if (finite(value)) return true; if (!object(value)) return false; - if ('ref' in value) { - if (!object(value.ref) || typeof value.ref.source !== 'string') return false; - const source = value.ref.source; - if (source === 'event' || source === 'combat' || source === 'equipment') { - return typeof value.ref.path === 'string'; - } - if (source === 'scenario') return typeof value.ref.key === 'string'; - if (source === 'reducer') return true; - if (source === 'pool') return typeof value.ref.pool === 'string'; - return false; - } + if ('ref' in value) return isValueReference(value.ref); if (value.op === 'clamp') { return isNumericExpression(value.value) && isNumericExpression(value.min) @@ -233,14 +256,20 @@ function isReducer(value: unknown): value is Reducer { function isMutation(value: unknown): value is ClauseMutation { if (!object(value) || typeof value.kind !== 'string') return false; if (value.kind === 'none') return true; - if (value.kind === 'clear') return typeof value.pool === 'string'; + if (value.kind === 'clear') { + return typeof value.pool === 'string' + && (value.entity === undefined || isEntitySelector(value.entity)); + } if (value.kind === 'transfer') { - return typeof value.pool === 'string' && object(value.from) && object(value.to) + return typeof value.pool === 'string' + && isEntitySelector(value.from) + && isEntitySelector(value.to) && (value.value === undefined || value.value === 'all' || isNumericExpression(value.value)); } return ['add', 'set', 'consume', 'derive'].includes(value.kind) && typeof value.pool === 'string' - && isNumericExpression(value.value); + && isNumericExpression(value.value) + && (value.entity === undefined || isEntitySelector(value.entity)); } function parseScenarioInputs( diff --git a/src/lib/calculations/mechanic-recipes.test.ts b/src/lib/calculations/mechanic-recipes.test.ts index e40a5a2..26640ab 100644 --- a/src/lib/calculations/mechanic-recipes.test.ts +++ b/src/lib/calculations/mechanic-recipes.test.ts @@ -25,6 +25,7 @@ const paramsByAction: Record = { ] }, headshot: {}, + body_shot: {}, headshot_kill: {}, weapon_kill: {}, target_hit_within_distance: { meters: 15 }, @@ -96,6 +97,27 @@ describe('mechanic recipe registry', () => { }); }); + it('maps head and body shots to authoritative weapon-hit predicates', () => { + for (const [action, headshot] of [['headshot', true], ['body_shot', false]] as const) { + expect(seedFor(action).clauses[0]).toMatchObject({ + when: 'weapon_hit', + if: { + kind: 'all', + predicates: [ + { kind: 'exists', value: { source: 'event', path: 'targetId' } }, + { kind: 'exists', value: { source: 'event', path: 'values.headshot' } }, + { + kind: 'compare', + left: { source: 'event', path: 'values.headshot' }, + operator: 'eq', + right: headshot + } + ] + } + }); + } + }); + it('models firing triggers as explicit lifecycle branches', () => { const continuous = seedFor('continuous_firing_duration'); expect(continuous.pools).toEqual([expect.objectContaining({ key: 'firing-active', max: 1 })]); diff --git a/src/lib/calculations/mechanic-recipes.ts b/src/lib/calculations/mechanic-recipes.ts index 21badaf..5151349 100644 --- a/src/lib/calculations/mechanic-recipes.ts +++ b/src/lib/calculations/mechanic-recipes.ts @@ -251,7 +251,23 @@ export const MECHANIC_RECIPE_REGISTRY: Readonly = { headshot: { key: 'headshot', createSeed: (_params) => supported({ clauses: [directClause('headshot', 'weapon_hit', { - kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: true + kind: 'all', + predicates: [ + targetExists, + { kind: 'exists', value: eventValue('headshot') }, + { kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: true } + ] + })] }) + }, + body_shot: { + key: 'body_shot', + createSeed: (_params) => supported({ clauses: [directClause('body-shot', 'weapon_hit', { + kind: 'all', + predicates: [ + targetExists, + { kind: 'exists', value: eventValue('headshot') }, + { kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: false } + ] })] }) }, headshot_kill: { diff --git a/src/lib/calculations/mechanic.test.ts b/src/lib/calculations/mechanic.test.ts index 312d4ee..700e118 100644 --- a/src/lib/calculations/mechanic.test.ts +++ b/src/lib/calculations/mechanic.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + MECHANIC_EVENT_REGISTRY, evaluateMechanicProgram, formatMechanicProgram, validateMechanicProgram, @@ -14,6 +15,16 @@ const custom = (encounterSeconds = 30, inputs: Record }); describe('structured mechanic runtime', () => { + it('advertises the authoritative weapon-hit target and headshot fields', () => { + expect(MECHANIC_EVENT_REGISTRY.weapon_hit).toMatchObject({ + description: expect.stringContaining('headshot boolean'), + fields: [ + { path: 'targetId', label: 'Target ID', type: 'string' }, + { path: 'values.headshot', label: 'Headshot', type: 'boolean' } + ] + }); + }); + it('models Superior Firepower as same-target sliding hits and one shared expiring stack pool', () => { const superiorFirepower: MechanicProgram = { id: 'superior-firepower', @@ -512,6 +523,151 @@ describe('structured mechanic runtime', () => { ]); }); + it('caps and refreshes target-scoped stacks independently', () => { + const program: MechanicProgram = { + id: 'target-trauma', + name: 'Target Trauma', + pools: [{ + key: 'trauma', + label: 'Trauma', + scope: 'target', + initial: 0, + min: 0, + max: 10, + expiresAfterSeconds: 1 + }], + clauses: [ + { + id: 'body-stack', + when: 'weapon_hit', + if: { + kind: 'compare', + left: { source: 'event', path: 'values.headshot' }, + operator: 'eq', + right: false + }, + reducer: { kind: 'none' }, + mutations: [{ + kind: 'add', + pool: 'trauma', + entity: { kind: 'event_target' }, + value: 1 + }] + }, + { + id: 'head-stack', + when: 'weapon_hit', + if: { + kind: 'compare', + left: { source: 'event', path: 'values.headshot' }, + operator: 'eq', + right: true + }, + reducer: { kind: 'none' }, + mutations: [{ + kind: 'add', + pool: 'trauma', + entity: { kind: 'event_target' }, + value: 2 + }] + } + ] + }; + const result = evaluateMechanicProgram(program, [ + { kind: 'weapon_hit', at: 0, targetId: 'enemy-a', values: { headshot: false } }, + { kind: 'weapon_hit', at: 0.25, targetId: 'enemy-b', values: { headshot: false } }, + { kind: 'weapon_hit', at: 0.5, targetId: 'enemy-a', values: { headshot: true } }, + ...Array.from({ length: 5 }, (_, index): MechanicEvent => ({ + kind: 'weapon_hit', + at: 0.55 + index * 0.05, + targetId: 'enemy-a', + values: { headshot: true } + })) + ], custom(0.9)); + expect(result.status).toBe('OK'); + expect(result.finalState).toEqual([ + expect.objectContaining({ + pool: 'trauma', + entityId: 'enemy-a', + value: 10, + expiresAt: 1.75 + }), + expect.objectContaining({ + pool: 'trauma', + entityId: 'enemy-b', + value: 1, + expiresAt: 1.25 + }) + ]); + }); + + it('uses the prior target stack count for live-Attribute damage on pool exit', () => { + const skillDamageId = 'skill-damage'; + const program: MechanicProgram = { + id: 'precise-trauma', + name: 'Precise Trauma', + pools: [{ + key: 'trauma', + label: 'Trauma', + scope: 'target', + initial: 0, + min: 0, + max: 10, + expiresAfterSeconds: 1 + }], + clauses: [{ + id: 'apply-trauma', + when: 'weapon_hit', + reducer: { kind: 'none' }, + mutations: [{ + kind: 'add', + pool: 'trauma', + entity: { kind: 'event_target' }, + value: 2 + }], + outcomes: [{ + id: 'trauma-damage', + label: 'Trauma Damage', + phase: 'on_exit', + kind: 'direct_damage', + scope: 'target', + basis: 'flat', + value: { ref: { source: 'attribute', attributeId: skillDamageId } }, + statePool: 'trauma', + perStack: true, + minStacks: 2 + }] + }] + }; + const result = evaluateMechanicProgram( + program, + [{ kind: 'weapon_hit', at: 0, targetId: 'enemy-a', values: { headshot: true } }], + custom(2), + { + maxHealth: 100, + currentHealth: 100, + attributes: { [skillDamageId]: 75 } + } + ); + expect(result.status).toBe('OK'); + expect(result.outcomes).toMatchObject([{ + outcomeId: 'trauma-damage', + targetId: 'enemy-a', + at: 1, + phase: 'on_exit', + value: 150, + resolvedValue: 150, + appliedValue: 150, + stateValue: 2 + }]); + expect(result.finalState).toContainEqual(expect.objectContaining({ + pool: 'trauma', + entityId: 'enemy-a', + value: 0, + expiresAt: null + })); + }); + it('models scaled OS Protocol restoration, an unscaled Health cost, and post-cost bonus damage', () => { const upgradeScale = { op: 'add' as const, diff --git a/src/lib/calculations/mechanic.ts b/src/lib/calculations/mechanic.ts index 605f09b..69d9c89 100644 --- a/src/lib/calculations/mechanic.ts +++ b/src/lib/calculations/mechanic.ts @@ -25,6 +25,11 @@ export type MechanicEventDefinition = { key: MechanicEventKind; label: string; description: string; + fields?: readonly { + path: string; + label: string; + type: 'boolean' | 'number' | 'string'; + }[]; }; export const MECHANIC_EVENT_REGISTRY: Readonly> = { @@ -36,7 +41,11 @@ export const MECHANIC_EVENT_REGISTRY: Readonly { + const current = outcomeState(outcome, context); + const previous = outcome.statePool ? before.get(outcome.statePool) ?? current : current; + return { + current, + previous, + effective: outcome.phase === 'on_exit' ? previous : current + }; + }; const eligible = (clause.outcomes ?? []).filter((outcome) => { if (phase === 'before' && outcome.phase !== 'on_event') return false; if (phase === 'after' && outcome.phase === 'on_event') return false; - const current = outcomeState(outcome, context); - const previous = outcome.statePool ? before.get(outcome.statePool) ?? current : current; + const { current, previous, effective } = stateValues(outcome); if (!phaseMatches(outcome.phase, previous, current)) return false; - if (outcome.minStacks !== undefined && current < outcome.minStacks) return false; - if (outcome.maxStacks !== undefined && current > outcome.maxStacks) return false; + if (outcome.minStacks !== undefined && effective < outcome.minStacks) return false; + if (outcome.maxStacks !== undefined && effective > outcome.maxStacks) return false; return true; }).sort((left, right) => { const priority = (outcome: MechanicOutcome) => @@ -910,7 +929,7 @@ function emitOutcomes( return priority(left) - priority(right); }); for (const outcome of eligible) { - const current = outcomeState(outcome, context); + const { effective: current } = stateValues(outcome); const baseValue = numericValue(outcome.value, context); const value = outcome.perStack ? baseValue * current : baseValue; const resolvedValue = resolveOutcomeValue(outcome, value, context); @@ -925,6 +944,7 @@ function emitOutcomes( continuousDeliveries?.push({ clauseId: clause.id, outcomeId: outcome.id, + targetId: context.event.targetId ?? null, phase: outcome.phase, scope: outcome.scope, resource: outcomeResource(outcome), @@ -957,6 +977,7 @@ function emitOutcomes( clauseId: clause.id, outcomeId: outcome.id, at, + targetId: context.event.targetId ?? null, phase: outcome.phase, kind: outcome.kind, scope: outcome.scope, @@ -1007,6 +1028,7 @@ function advanceContinuousDeliveries( clauseId: delivery.clauseId, outcomeId: delivery.outcomeId, at: through, + targetId: delivery.targetId, phase: delivery.phase, kind: 'restoration', scope: delivery.scope, diff --git a/src/lib/components/MechanicLanguageHelp.svelte b/src/lib/components/MechanicLanguageHelp.svelte index 96eff39..7a692a8 100644 --- a/src/lib/components/MechanicLanguageHelp.svelte +++ b/src/lib/components/MechanicLanguageHelp.svelte @@ -40,10 +40,11 @@
IF
Conditions checked against the event, Agent, target, team, or encounter.
TRACK
A count, duration, scalar, mark, or stack pool shared by branches.
ADD / SET / CONSUME
How a matching branch changes tracked state.
+
FOR EVENT TARGET
Binds a target-scoped state change to the enemy named by the event.
APPLY
The Attribute or direct result and the phase when it takes effect.
UNTIL
The expiry, reset, or transfer policy for tracked state.
EVERY
An explicit periodic interval. ISAC does not infer a missing cadence.
-
PHASE
Whether an outcome runs before state changes, after them, or while state remains active.
+
PHASE
Whether an outcome runs before state changes, after them, while state remains active, or when state expires.
diff --git a/src/lib/components/isac/MechanicProgramEditor.svelte b/src/lib/components/isac/MechanicProgramEditor.svelte index 115cda0..27bd1bd 100644 --- a/src/lib/components/isac/MechanicProgramEditor.svelte +++ b/src/lib/components/isac/MechanicProgramEditor.svelte @@ -235,6 +235,7 @@ | 'static' | 'event' | 'attribute' + | 'attribute_value' | 'reducer' | 'scenario' | 'protocol_upgrades' @@ -243,6 +244,7 @@ const valueModelOptions = $derived([ { value: 'static', label: 'STATIC' }, { value: 'event', label: 'EVENT FIELD' }, + { value: 'attribute_value', label: 'LIVE ATTRIBUTE' }, { value: 'attribute', label: 'ATTRIBUTE ÷ 100' }, { value: 'reducer', label: 'REDUCER' }, { value: 'scenario', label: 'SCENARIO' }, @@ -298,6 +300,14 @@ }; } + function liveAttributeExpression(value: number, attributeId: string): NumericExpression { + return { + op: 'multiply', + left: value, + right: { ref: { source: 'attribute', attributeId } } + }; + } + function outcomeValueModel(outcome: ConditionalMechanicOutcomeDraft): OutcomeValueModel { if (valueModelOverrides[outcome.key]) return valueModelOverrides[outcome.key]; const expression = Object.values(outcome.variants)[0]?.expression; @@ -313,6 +323,7 @@ if (expression.right.ref.source === 'event') return 'event'; if (expression.right.ref.source === 'scenario') return 'scenario'; if (expression.right.ref.source === 'reducer') return 'reducer'; + if (expression.right.ref.source === 'attribute') return 'attribute_value'; } if ( 'op' in expression.right @@ -356,6 +367,15 @@ function outcomeAttributeReference(outcome: ConditionalMechanicOutcomeDraft) { const expression = Object.values(outcome.variants)[0]?.expression; + if ( + expression + && typeof expression === 'object' + && 'op' in expression + && expression.op === 'multiply' + && typeof expression.right === 'object' + && 'ref' in expression.right + && expression.right.ref.source === 'attribute' + ) return expression.right.ref.attributeId; if ( expression && typeof expression === 'object' @@ -380,6 +400,9 @@ if (model === 'attribute') return reference ? attributePercentExpression(value, reference) : undefined; + if (model === 'attribute_value') return reference + ? liveAttributeExpression(value, reference) + : undefined; if (model === 'scenario') return reference ? referenceExpression('scenario', reference, value) : undefined; @@ -398,7 +421,9 @@ ) { const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex]; const nextOverrides = { ...valueModelOverrides }; - if (model === 'attribute' && !reference) nextOverrides[outcome.key] = 'attribute'; + if ((model === 'attribute' || model === 'attribute_value') && !reference) { + nextOverrides[outcome.key] = model; + } else delete nextOverrides[outcome.key]; valueModelOverrides = nextOverrides; const variants = Object.fromEntries(Object.entries(outcome.variants).map(([key, variant]) => { @@ -442,6 +467,17 @@ variants: { base: { value, expression } } })); } + if (model === 'attribute_value') { + return liveAttributeExpression(value, outcomeAttributeReference({ + key: 'temporary', + label: 'Temporary', + phase: 'on_event', + kind: 'marker', + scope: 'self', + basis: 'flat', + variants: { base: { value, expression } } + })); + } if (model === 'reducer') { return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } }; } @@ -452,8 +488,11 @@ function addMutation(clauseIndex: number) { const pool = rule.pools[0]?.key; if (!pool) return; + const entity = rule.pools[0]?.scope === 'target' + ? { kind: 'event_target' as const } + : undefined; updateClause(clauseIndex, { - mutations: [...rule.clauses[clauseIndex].mutations, { kind: 'add', pool, value: 1 }] + mutations: [...rule.clauses[clauseIndex].mutations, { kind: 'add', pool, value: 1, entity }] }); } @@ -461,9 +500,25 @@ const mutations = [...rule.clauses[clauseIndex].mutations]; const current = mutations[mutationIndex]; const nextPool = pool ?? ('pool' in current ? current.pool : rule.pools[0]?.key ?? ''); + const nextPoolDefinition = rule.pools.find((candidate) => candidate.key === nextPool); + const entity = nextPoolDefinition?.scope === 'target' + ? ('entity' in current && current.entity ? current.entity : { kind: 'event_target' as const }) + : undefined; mutations[mutationIndex] = kind === 'clear' - ? { kind, pool: nextPool } - : { kind, pool: nextPool, value: value ?? ('value' in current && typeof current.value === 'number' ? current.value : 1) }; + ? { kind, pool: nextPool, entity } + : { kind, pool: nextPool, value: value ?? ('value' in current && typeof current.value === 'number' ? current.value : 1), entity }; + updateClause(clauseIndex, { mutations }); + } + + function updateMutationEntity( + clauseIndex: number, + mutationIndex: number, + kind: 'event_target' | 'event_actor' + ) { + const mutations = [...rule.clauses[clauseIndex].mutations]; + const mutation = mutations[mutationIndex]; + if (!('pool' in mutation) || mutation.kind === 'transfer') return; + mutations[mutationIndex] = { ...mutation, entity: { kind } }; updateClause(clauseIndex, { mutations }); } @@ -680,6 +735,15 @@ {#if mutation.kind !== 'clear'} {/if} + {#if 'pool' in mutation && rule.pools.find((pool) => pool.key === mutation.pool)?.scope === 'target'} + updateMutationEntity(clauseIndex,mutationIndex,values[0] as 'event_target'|'event_actor')} + /> + {/if} {:else} @@ -747,14 +811,14 @@ columns={Math.min(4,rule.scenarioInputs.length)} onchange={(values) => setOutcomeValueModel(clauseIndex,outcomeIndex,'scenario',values[0])} /> - {:else if outcomeValueModel(outcome) === 'attribute'} + {:else if outcomeValueModel(outcome) === 'attribute' || outcomeValueModel(outcome) === 'attribute_value'} setOutcomeValueModel(clauseIndex,outcomeIndex,'attribute',value)} + onvaluechange={(value) => setOutcomeValueModel(clauseIndex,outcomeIndex,outcomeValueModel(outcome),value)} /> {/if} {#if rule.pools.length} @@ -823,7 +887,7 @@ .branch-readout{display:grid;align-content:center;gap:3px;min-height:44px;padding:7px 9px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}.branch-readout span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.branch-readout strong{font-size:var(--shd-type-supporting)} .reducer-controls{display:grid;grid-template-columns:minmax(0,1fr) repeat(2,minmax(130px,.25fr));align-items:end;gap:var(--shd-gap-sm);padding:0 var(--shd-gap-sm) var(--shd-gap-sm)} .mutation-panel{margin:0 var(--shd-gap-sm) var(--shd-gap-sm);border:1px solid var(--shd-border-soft)} - .mutation-row{display:grid;grid-template-columns:minmax(300px,1fr) minmax(260px,1fr) minmax(110px,.35fr) auto;align-items:end;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border-soft)} + .mutation-row{display:grid;grid-template-columns:minmax(260px,1fr) minmax(220px,1fr) minmax(110px,.35fr) minmax(240px,1fr) auto;align-items:end;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border-soft)} .outcome-card{display:grid;gap:8px;margin:var(--shd-gap-sm);padding:9px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)} .outcome-grid{display:grid;grid-template-columns:minmax(200px,1fr) minmax(360px,1.4fr) minmax(120px,.3fr);align-items:end;gap:8px} .variant-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px} diff --git a/src/lib/server/ai/mechanic-authoring-prompt.test.ts b/src/lib/server/ai/mechanic-authoring-prompt.test.ts index ab802e3..0b345dd 100644 --- a/src/lib/server/ai/mechanic-authoring-prompt.test.ts +++ b/src/lib/server/ai/mechanic-authoring-prompt.test.ts @@ -78,7 +78,7 @@ describe('AI mechanic authoring prompt', () => { recipeKeys: [] })); - expect(AI_MECHANIC_PROMPT_VERSION).toBe(4); + expect(AI_MECHANIC_PROMPT_VERSION).toBe(5); expect(input.canonicalCatalog.grammar.outcome.optional).toEqual( expect.arrayContaining(['delivery', 'durationSeconds']) ); @@ -115,7 +115,7 @@ describe('AI mechanic authoring prompt', () => { expect.stringContaining('Do not replace continuous delivery with a periodic reducer') ])); expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain( - 'Prompt contract version: 4' + 'Prompt contract version: 5' ); const advertised = structuredClone( @@ -156,4 +156,89 @@ describe('AI mechanic authoring prompt', () => { }); expect(parsed.ok).toBe(true); }); + + it('advertises Precise Trauma through target expiry without inventing target tick iteration', () => { + const skillDamageId = 'skill-damage-catalog-id'; + const input = JSON.parse(buildAiMechanicAuthoringInput({ + request: { + ...request, + context: { + ...request.context, + sourceName: 'Precise Trauma', + sourceDescription: + 'Hits apply a debuff for 1 second, up to 10 stacks. Body shots add 1 stack and headshots add 2 stacks.' + } + }, + attributes: [{ + id: skillDamageId, + name: 'Skill Damage', + description: null, + unit: null, + type: null, + category: null, + calculationType: null + }], + events: [{ key: 'weapon_hit', label: 'Weapon hit' }], + actions: [ + { key: 'body_shot', label: 'Body shot' }, + { key: 'headshot', label: 'Headshot' } + ], + recipeKeys: ['body_shot', 'headshot'] + })); + const advertised = structuredClone(input.canonicalCatalog.grammar.preciseTraumaShape); + const outcome = advertised.clauses[0].outcomes[0]; + const baseVariant = JSON.parse(JSON.stringify( + outcome.variants['replace-with-each-required-variant-key'] + ).replaceAll( + 'replace-with-the-exact-skill-damage-catalog-id', + skillDamageId + )); + outcome.variants = { + I: structuredClone(baseVariant), + II: structuredClone(baseVariant) + }; + + expect(advertised.pools[0]).toMatchObject({ + scope: 'target', + max: 10, + expiresAfterSeconds: 1 + }); + expect(advertised.clauses[0].mutations[0]).toEqual({ + kind: 'add', + pool: 'trauma', + entity: { kind: 'event_target' }, + value: 1 + }); + expect(advertised.clauses[1].mutations[0]).toMatchObject({ value: 2 }); + expect(outcome).toMatchObject({ + phase: 'on_exit', + kind: 'direct_damage', + scope: 'target', + statePool: 'trauma', + perStack: true, + variants: { + I: { + expression: { + ref: { + source: 'attribute', + attributeId: skillDamageId + } + } + } + } + }); + expect(input.examplesAndRules).toEqual(expect.arrayContaining([ + expect.stringContaining('uses the entity previous state'), + expect.stringContaining('without inventing generic repeated target ticks') + ])); + expect(JSON.stringify(input.canonicalCatalog.grammar.preciseTraumaShape)) + .not.toContain('entitiesFromPool'); + + const parsed = parseConditionalMechanicDraft(advertised, { + variantKeys: ['I', 'II'], + allowedAttributeIds: new Set([skillDamageId]), + allowedRecipeKeys: new Set(['body_shot', 'headshot']) + }); + expect(parsed.ok).toBe(true); + }); }); diff --git a/src/lib/server/ai/mechanic-authoring-prompt.ts b/src/lib/server/ai/mechanic-authoring-prompt.ts index af93bd1..a9988ec 100644 --- a/src/lib/server/ai/mechanic-authoring-prompt.ts +++ b/src/lib/server/ai/mechanic-authoring-prompt.ts @@ -2,7 +2,7 @@ import type { AiMechanicAuthoringRequest } from '$lib/ai/mechanic-authoring'; import { CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES } from '$lib/calculations/mechanic-editor'; import type { AiMechanicAttribute } from './mechanic-authoring-context'; -export const AI_MECHANIC_PROMPT_VERSION = 4; +export const AI_MECHANIC_PROMPT_VERSION = 5; const mechanicsGrammar = { version: 1, @@ -19,7 +19,9 @@ const mechanicsGrammar = { scope: ['agent', 'target'], optional: ['min', 'max', 'bestCaseValue', 'expiresAfterSeconds'], expirationSemantics: - 'expiresAfterSeconds is one shared pool timer. Every write to the pool resets that timer for the entire pool, and expiry resets the full pool to its initial value.' + 'expiresAfterSeconds is one timer per pool entity. Every write resets that entity timer, and expiry resets only that entity to the pool initial value.', + targetScopeSemantics: + 'A target-scoped pool stores one independent value and expiry timer per resolved entity. Use mutation.entity event_target when a weapon-hit mechanic binds state to the enemy that was hit.' }, clause: { required: ['key', 'event', 'if', 'reducer', 'mutations', 'outcomes'], @@ -94,6 +96,12 @@ const mechanicsGrammar = { mutationShapes: [ { kind: 'none' }, { kind: 'add', pool: 'stacks', value: 1 }, + { + kind: 'add', + pool: 'target-stacks', + entity: { kind: 'event_target' }, + value: 1 + }, { kind: 'set', pool: 'active', value: 1 }, { kind: 'consume', pool: 'stacks', value: 1 }, { kind: 'derive', pool: 'nearby-enemies', value: { ref: { source: 'reducer' } } }, @@ -138,6 +146,98 @@ const mechanicsGrammar = { } } }, + preciseTraumaShape: { + version: 1, + simulationReadiness: 'ready', + scenarioInputs: [], + pools: [{ + key: 'trauma', + label: 'Trauma', + stateKind: 'stack', + scope: 'target', + initial: 0, + min: 0, + max: 10, + bestCaseValue: 10, + expiresAfterSeconds: 1 + }], + clauses: [ + { + key: 'body-shot-trauma', + label: 'Body shot adds one Trauma stack', + recipeKey: 'body_shot', + event: 'weapon_hit', + if: { + kind: 'all', + predicates: [ + { kind: 'exists', value: { source: 'event', path: 'targetId' } }, + { kind: 'exists', value: { source: 'event', path: 'values.headshot' } }, + { + kind: 'compare', + left: { source: 'event', path: 'values.headshot' }, + operator: 'eq', + right: false + } + ] + }, + reducer: { kind: 'none' }, + mutations: [{ + kind: 'add', + pool: 'trauma', + entity: { kind: 'event_target' }, + value: 1 + }], + outcomes: [{ + key: 'trauma-skill-damage', + label: 'Trauma Skill Damage', + phase: 'on_exit', + kind: 'direct_damage', + scope: 'target', + basis: 'flat', + statePool: 'trauma', + perStack: true, + variants: { + 'replace-with-each-required-variant-key': { + value: 0, + expression: { + ref: { + source: 'attribute', + attributeId: 'replace-with-the-exact-skill-damage-catalog-id' + } + } + } + } + }] + }, + { + key: 'headshot-trauma', + label: 'Headshot adds two Trauma stacks', + recipeKey: 'headshot', + event: 'weapon_hit', + if: { + kind: 'all', + predicates: [ + { kind: 'exists', value: { source: 'event', path: 'targetId' } }, + { kind: 'exists', value: { source: 'event', path: 'values.headshot' } }, + { + kind: 'compare', + left: { source: 'event', path: 'values.headshot' }, + operator: 'eq', + right: true + } + ] + }, + reducer: { kind: 'none' }, + mutations: [{ + kind: 'add', + pool: 'trauma', + entity: { kind: 'event_target' }, + value: 2 + }], + outcomes: [] + } + ] + }, variantValue: { required: ['value'], optional: ['expression'] @@ -241,6 +341,10 @@ export function buildAiMechanicAuthoringInput(input: { 'An Attribute whose catalog unit is % resolves to its displayed percentage number, such as 20 for 20%. Divide that reference by 100 when wording applies it as a percentage multiplier, such as 240 × 20%.', 'Continuous Health restoration is supported. Use kind:"restoration", resource:"health", delivery:"continuous", and a positive durationSeconds. The variant expression resolves the total restoration across that duration, not a per-second value. Do not replace continuous delivery with a periodic reducer.', 'Each continuous-restoration activation creates an independent constant-rate stream. Overlapping streams add, and restoration above the resource maximum is lost when it occurs.', + 'A target-scoped pool owns an independent value and expiry timer for each entity. Bind hit-created target state with entity:{kind:"event_target"}; do not collapse different enemies into one Agent-scoped pool.', + 'Writing a target-scoped pool refreshes only that target entity timer. An on_exit outcome runs when that entity expires and uses the entity previous state for stack bands and perStack multiplication, even though the stored pool value has already reset.', + 'When an outcome value is exactly a live Attribute amount, use the Attribute reference directly as the variant expression. Divide by 100 only when a displayed percentage is being used as a multiplier.', + 'Precise Trauma is representable as one target-scoped stack pool with a one-second expiry: body shots add one stack, headshots add two, and one on_exit direct-damage outcome uses the expiring target previous stack count. This models the stated one-second debuff without inventing generic repeated target ticks.', 'OS Protocol upgrade scaling may use equipment.protocolTalentUpgrades only when allowProtocolUpgradeScaling is true.', 'Assigned-ability scope may be used only when allowAssignedAbility is true.', 'If cadence is not stated, ask rather than assuming a one-second interval.', diff --git a/src/lib/server/conditional-effects.test.ts b/src/lib/server/conditional-effects.test.ts index 92c45b5..4884eb2 100644 --- a/src/lib/server/conditional-effects.test.ts +++ b/src/lib/server/conditional-effects.test.ts @@ -182,14 +182,27 @@ describe('conditional mechanic persistence adapter', () => { version: 1, simulationReadiness: 'ready', scenarioInputs: [], - pools: [], + pools: [{ + key: 'marked-target', + label: 'Marked Target', + stateKind: 'target_mark', + scope: 'target', + initial: 0, + min: 0, + max: 1 + }], clauses: [{ key: 'weapon-kill', label: 'Weapon kill', event: 'enemy_killed', if: { kind: 'true' }, reducer: { kind: 'none' }, - mutations: [], + mutations: [{ + kind: 'set', + pool: 'marked-target', + entity: { kind: 'event_target' }, + value: 1 + }], outcomes: [{ key: 'restore-health', label: 'Restore Health', @@ -204,6 +217,51 @@ describe('conditional mechanic persistence adapter', () => { I: { value: 240 }, II: { value: 336 } } + }, { + key: 'target-detonation', + label: 'Target Detonation', + phase: 'on_exit', + kind: 'direct_damage', + scope: 'target', + basis: 'flat', + statePool: 'marked-target', + perStack: true, + variants: { + I: { + value: 240, + expression: { + op: 'multiply', + left: 240, + right: { + op: 'divide', + left: { + ref: { + source: 'attribute', + attributeId: '82a95611-283b-4fd8-a6ed-3567a316f36e' + } + }, + right: 100 + } + } + }, + II: { + value: 336, + expression: { + op: 'multiply', + left: 336, + right: { + op: 'divide', + left: { + ref: { + source: 'attribute', + attributeId: '82a95611-283b-4fd8-a6ed-3567a316f36e' + } + }, + right: 100 + } + } + } + } }] }] }); @@ -213,7 +271,9 @@ describe('conditional mechanic persistence adapter', () => { )).all(); expect(saved).toEqual([ { delivery_mode: 'continuous', delivery_duration_seconds: 3 }, - { delivery_mode: 'continuous', delivery_duration_seconds: 3 } + { delivery_mode: 'instant', delivery_duration_seconds: null }, + { delivery_mode: 'continuous', delivery_duration_seconds: 3 }, + { delivery_mode: 'instant', delivery_duration_seconds: null } ]); const loaded = await loadConditionalRuleDraft( database, @@ -229,5 +289,39 @@ describe('conditional mechanic persistence adapter', () => { II: { value: 336 } } }); + expect(loaded?.pools[0]).toMatchObject({ + key: 'marked-target', + stateKind: 'target_mark', + scope: 'target' + }); + expect(loaded?.clauses[0].mutations).toEqual([{ + kind: 'set', + pool: 'marked-target', + entity: { kind: 'event_target' }, + value: 1 + }]); + expect(loaded?.clauses[0].outcomes[1]).toMatchObject({ + phase: 'on_exit', + kind: 'direct_damage', + scope: 'target', + statePool: 'marked-target', + perStack: true, + variants: { + I: { + value: 240, + expression: expect.objectContaining({ + right: expect.objectContaining({ + left: { + ref: { + source: 'attribute', + attributeId: '82a95611-283b-4fd8-a6ed-3567a316f36e' + } + } + }) + }) + }, + II: { value: 336 } + } + }); }); }); diff --git a/src/routes/isac/mechanic-reference/+page.svelte b/src/routes/isac/mechanic-reference/+page.svelte index b60b2e8..27d7da6 100644 --- a/src/routes/isac/mechanic-reference/+page.svelte +++ b/src/routes/isac/mechanic-reference/+page.svelte @@ -88,7 +88,7 @@ ]; const mutations = [ - { syntax: 'ADD value TO pool', use: 'Increases state, respecting the pool maximum.' }, + { syntax: 'ADD value TO pool FOR EVENT TARGET', use: 'Increases one enemy’s target-scoped state, respecting the pool maximum.' }, { syntax: 'SET value AS pool', use: 'Replaces the current pool value.' }, { syntax: 'CONSUME value FROM pool', use: 'Spends available state without falling below the pool minimum.' }, { syntax: 'CLEAR pool', use: 'Resets the selected state pool.' }, @@ -99,7 +99,7 @@ { syntax: 'ON_EVENT', use: 'Applies against combat state as the event arrives.' }, { syntax: 'AFTER_EVENT', use: 'Applies after branch mutations and resource costs.' }, { syntax: 'ON_ENTER', use: 'Applies when tracked state enters an active range.' }, - { syntax: 'ON_EXIT', use: 'Applies when tracked state leaves an active range.' }, + { syntax: 'ON_EXIT', use: 'Applies when tracked state leaves an active range, using its final pre-exit value for stack thresholds and scaling.' }, { syntax: 'WHILE_ACTIVE', use: 'Remains active while the referenced state satisfies its stack range.' } ]; @@ -155,6 +155,22 @@ 'ON_EVENT: APPLY 2 CURRENT_HEALTH_PERCENT // Health cost', 'AFTER_EVENT: APPLY (0.6 * (1 + (EQUIPMENT.protocolTalentUpgrades / 5))) CURRENT_HEALTH_PERCENT USING CURRENT HEALTH // Additional damage' ]; + + const preciseTrauma = [ + 'MECHANIC PRECISE TRAUMA', + 'STATE trauma [TARGET] 0..10; EXPIRES 1s AFTER WRITE', + '', + 'WHEN WEAPON HIT // Body shot', + 'IF EVENT.targetId EXISTS AND EVENT.values.headshot == false', + 'ADD 1 TO trauma FOR EVENT TARGET', + '', + 'WHEN WEAPON HIT // Headshot', + 'IF EVENT.targetId EXISTS AND EVENT.values.headshot == true', + 'ADD 2 TO trauma FOR EVENT TARGET', + '', + 'WHEN SIMULATION TICK // Shared refreshed expiry', + 'ON_EXIT: APPLY (1 * ATTRIBUTE[skill-damage-id]) FLAT DIRECT_DAMAGE PER STACK TO EVENT TARGET' + ]; @@ -210,6 +226,13 @@
{event.label}{event.key}

{event.description}

+ {#if event.fields?.length} +
    + {#each event.fields as field} +
  • {field.path}{field.label} · {field.type.toUpperCase()}
  • + {/each} +
+ {/if}
{/each} @@ -318,7 +341,7 @@
@@ -376,6 +399,10 @@
10

WORKED MECHANICS

+
+

PRECISE TRAUMA

+

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.

+

SUPERIOR FIREPOWER

Nine hits on one target add a Relentless stack. Each write refreshes the ten-second pool timer.

@@ -386,6 +413,7 @@
+
@@ -652,6 +680,25 @@ line-height: 1.45; } + .event-registry ul { + display: grid; + gap: 4px; + margin: 2px 0 0; + padding: 0; + list-style: none; + } + + .event-registry li { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 6px; + padding-top: 5px; + border-top: 1px solid var(--shd-border-soft); + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + } + .action-registry { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -937,7 +984,7 @@ .example-scripts { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; padding: 0 12px 12px; } diff --git a/src/routes/isac/talents/+page.server.ts b/src/routes/isac/talents/+page.server.ts index 0b061af..1149724 100644 --- a/src/routes/isac/talents/+page.server.ts +++ b/src/routes/isac/talents/+page.server.ts @@ -219,28 +219,33 @@ export const actions: Actions = { valueII: effect.variants.II.value })); const validationErrors: TalentValidationError[] = []; + const requiresCompleteMechanic = conditionalRule.simulationReadiness !== 'captured_only'; if (name.length < 2 || !['Weapon Talent 1', 'Weapon Talent 2', 'Backpack', 'Body Armor'].includes(category)) { validationErrors.push({ area: 'identity', message: 'Provide a valid talent name and category. Exotic talents are managed from their owning weapon.' }); } if (!descriptionTemplate) validationErrors.push({ area: 'description', message: 'Enter a shared description template.' }); - if (!conditionalRule.clauses.length) validationErrors.push({ area: 'mechanic', message: 'Add at least one mechanic branch.' }); - if (!sharedEffects.length) validationErrors.push({ area: 'outcomes', message: 'Add at least one numbered value outcome.' }); + if (requiresCompleteMechanic && !conditionalRule.clauses.length) { + validationErrors.push({ area: 'mechanic', message: 'Add at least one mechanic branch or mark the talent CAPTURE ONLY.' }); + } + if (requiresCompleteMechanic && !sharedEffects.length) { + validationErrors.push({ area: 'outcomes', message: 'Add at least one numbered value outcome or mark the talent CAPTURE ONLY.' }); + } if (sharedEffects.some((effect) => !Number.isFinite(effect.valueI) || !Number.isFinite(effect.valueII))) { validationErrors.push({ area: 'outcomes', message: 'Every numbered outcome needs valid Tier I and Tier II values.' }); } const missingSlots = missingTalentDescriptionSlots(descriptionTemplate, descriptionOutcomes); - if (missingSlots.length) { + if (requiresCompleteMechanic && missingSlots.length) { validationErrors.push({ area: 'description', message: `Description placeholder${missingSlots.length === 1 ? '' : 's'} ${missingSlots.map((slot) => `{${slot}}`).join(', ')} must reference numbered outcomes.` }); validationErrors.push({ area: 'outcomes', message: 'Add or renumber outcomes to match every numbered description placeholder.' }); } - if (descriptionTemplate.includes('{duration}') && durationSeconds === null) { + if (requiresCompleteMechanic && descriptionTemplate.includes('{duration}') && durationSeconds === null) { validationErrors.push({ area: 'description', message: 'The description uses {duration}, but no duration is defined.' }); validationErrors.push({ area: 'mechanic', message: 'Define a duration for the {duration} description placeholder.' }); } - if (descriptionTemplate.includes('{max_stacks}') && maxStacks === null) { + if (requiresCompleteMechanic && descriptionTemplate.includes('{max_stacks}') && maxStacks === null) { validationErrors.push({ area: 'description', message: 'The description uses {max_stacks}, but no maximum stack count is defined.' }); validationErrors.push({ area: 'mechanic', message: 'Define a pool maximum for the {max_stacks} description placeholder.' }); } diff --git a/src/routes/isac/talents/+page.svelte b/src/routes/isac/talents/+page.svelte index 255570f..24534ed 100644 --- a/src/routes/isac/talents/+page.svelte +++ b/src/routes/isac/talents/+page.svelte @@ -42,28 +42,9 @@ simulationReadiness: 'captured_only', scenarioInputs: [], pools: [], - clauses: [{ - key: 'primary', - label: 'Primary', - event: 'weapon_hit', - if: { kind: 'true' }, - reducer: { kind: 'none' }, - mutations: [], - outcomes: [{ - key: 'effect-1', - label: 'Effect 1', - phase: 'while_active', - kind: 'attribute_modifier', - scope: 'equipped_weapon', - attributeId: '', - basis: 'flat', - perStack: false, - descriptionSlot: 1, - variants: { I: { value: 0 }, II: { value: 0 } } - }] - }] + clauses: [] }); - const blankTalent = () => ({ id: '', name: '', iconUrl: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', conditionalRule: blankRule() }); + const blankTalent = () => ({ id: '', name: '', iconUrl: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', conditionalRule: blankRule(), modelingOpen: false }); const editTalent = (row: any) => { const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', effects: [] }; const one = tier('I'), two = tier('II'); @@ -72,7 +53,8 @@ descriptionTemplate: row.descriptionTemplate || one.description, tierIDescription: one.description, tierIIDescription: two.description, - conditionalRule: row.conditionalRule ?? blankRule() + conditionalRule: row.conditionalRule ?? blankRule(), + modelingOpen: Boolean(row.conditionalRule?.clauses?.length) }; }; const configured = (tiers: Array<{ tier: unknown }>, name: string) => tiers.some((item) => item.tier === name); @@ -105,6 +87,7 @@ if (form.talentMode === 'add') iconResetToken += 1; talent = form.talentMode === 'add' ? blankTalent() : null; } else if (talentErrors.length) { + if (hasErrors(['mechanic', 'outcomes'])) talent.modelingOpen = true; void tick().then(() => validationSummary?.focus()); } }); @@ -143,20 +126,26 @@
TIER I PREVIEW

{preview('I') || 'Description preview'}

TIER II PREVIEW

{preview('II') || 'Description preview'}

-
- - {#each [...errorsFor('mechanic'), ...errorsFor('outcomes')] as error}{error.message}{/each} -
+
+ + COMBAT MODELING{talent.conditionalRule.simulationReadiness === 'captured_only' ? 'CAPTURE ONLY · MODEL LATER' : `${talent.conditionalRule.clauses.length} CONFIGURED ${talent.conditionalRule.clauses.length === 1 ? 'BRANCH' : 'BRANCHES'}`} + {talent.modelingOpen ? 'COLLAPSE' : 'EXPAND'} + +
+ + {#each [...errorsFor('mechanic'), ...errorsFor('outcomes')] as error}{error.message}{/each} +
+