From 568e46cd05e83886c3c3fc212bc772fcb1380072 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 27 Jul 2026 21:32:04 -0400 Subject: [PATCH] Support live attribute restoration mechanics --- database/schema.sql | 6 + docs/DESIGN_SYSTEM.md | 11 +- docs/architecture.md | 20 +- scripts/db-migrate.mjs | 19 ++ src/lib/builder/loadout-combat.ts | 10 +- src/lib/calculations/mechanic-editor.test.ts | 84 ++++++++ src/lib/calculations/mechanic-editor.ts | 43 ++++ .../calculations/mechanic-persistence.test.ts | 65 ++++++ src/lib/calculations/mechanic-persistence.ts | 16 ++ src/lib/calculations/mechanic.test.ts | 165 +++++++++++++++ src/lib/calculations/mechanic.ts | 199 +++++++++++++++++- .../components/SearchableAssignment.svelte | 5 +- .../isac/MechanicProgramEditor.svelte | 94 ++++++++- .../ai/mechanic-authoring-prompt.test.ts | 61 ++++++ .../server/ai/mechanic-authoring-prompt.ts | 7 +- src/lib/server/conditional-effects.test.ts | 159 +++++++++++++- src/lib/server/conditional-effects.ts | 35 ++- .../isac/mechanic-reference/+page.svelte | 9 + src/routes/isac/talents/+page.svelte | 2 +- 19 files changed, 979 insertions(+), 31 deletions(-) create mode 100644 src/lib/server/ai/mechanic-authoring-prompt.test.ts diff --git a/database/schema.sql b/database/schema.sql index 591e13c..40a7509 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -675,6 +675,8 @@ CREATE TABLE IF NOT EXISTS conditional_effect_outcomes ( )), combat_timing TEXT NOT NULL DEFAULT 'current' CHECK(combat_timing IN ('pre_event','current')), resource_kind TEXT CHECK(resource_kind IS NULL OR resource_kind IN ('health','armor','ammo')), + delivery_mode TEXT NOT NULL DEFAULT 'instant' CHECK(delivery_mode IN ('instant','continuous')), + delivery_duration_seconds REAL CHECK(delivery_duration_seconds IS NULL OR delivery_duration_seconds>0), min_state_value REAL, max_state_value REAL, description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1), @@ -685,6 +687,10 @@ CREATE TABLE IF NOT EXISTS conditional_effect_outcomes ( CHECK(min_state_value IS NULL OR min_state_value>=0), CHECK(max_state_value IS NULL OR max_state_value>=0), CHECK(min_state_value IS NULL OR max_state_value IS NULL OR max_state_value>=min_state_value), + CHECK( + (delivery_mode='instant' AND delivery_duration_seconds IS NULL) + OR (delivery_mode='continuous' AND delivery_duration_seconds IS NOT NULL) + ), CHECK( (application_kind='active_modifier' AND stat_attribute_id IS NOT NULL AND damage_basis IS NULL) OR (application_kind='direct_damage' AND damage_basis IS NOT NULL) diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index ce5aa68..af71cce 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -2009,8 +2009,15 @@ 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, event fields, reducer output, source-defined scenario controls, and OS -Protocol upgrade scaling where the source permits it. +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. +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 +delivered across that duration; switching back to instant clears the duration. Action Family recipes are optional authoring starters. Present the recipe catalog as a searchable assignment with only the parameters required by the diff --git a/docs/architecture.md b/docs/architecture.md index ffff3e0..a4114f7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -425,8 +425,10 @@ The program graph uses three child tables: 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, - event-field values, reducer output, source-defined scenario values, and OS - Protocol upgrade scaling. + live Attribute percentages, 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. The generic runtime in `src/lib/calculations/mechanic.ts` processes combat events in timestamp order. Predicates decide whether an event enters a clause. Reducers @@ -434,6 +436,20 @@ 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. +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 +coefficient to a percentage divides the Attribute value by 100. Evaluation +fails when the loadout does not supply a referenced Attribute; the runtime does +not substitute zero. + +Continuous restoration resolves the outcome's total when the branch activates, +then delivers that total at a constant rate over the configured duration. +Activations create independent streams, overlapping streams add, and restoration +above the resource maximum is lost when it occurs. The runtime integrates +delivery at event and expiry boundaries before processing the event at that +timestamp, which keeps results independent of an arbitrary tick interval. + Periodic behavior requires an interval in the reducer. The runtime does not infer a one-second cadence from missing data. A mechanic with an unknown cadence uses `requires_assumptions`, identifies the missing input, and stays out of the diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index 5f52639..49871b6 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -1227,6 +1227,25 @@ if (sourceOwnedClauseColumns.some((column) => column.name === 'trigger_id')) { ON conditional_effect_outcomes(rule_id,variant_key,outcome_key,sort_order)`); } +// Legacy conditional-mechanic table rebuilds above predate delivery metadata. +// Recheck after those migrations so their replacement tables receive the +// additive columns too. +const postRebuildOutcomeColumns = await ( + await database.prepare("PRAGMA table_info('conditional_effect_outcomes')") +).all(); +if (!postRebuildOutcomeColumns.some((column) => column.name === 'delivery_mode')) { + console.log('Adding outcome delivery modes to conditional mechanics.'); + await database.exec(`ALTER TABLE conditional_effect_outcomes + ADD COLUMN delivery_mode TEXT NOT NULL DEFAULT 'instant' + CHECK(delivery_mode IN ('instant','continuous'))`); +} +if (!postRebuildOutcomeColumns.some((column) => column.name === 'delivery_duration_seconds')) { + console.log('Adding continuous-delivery durations to conditional mechanics.'); + await database.exec(`ALTER TABLE conditional_effect_outcomes + ADD COLUMN delivery_duration_seconds REAL + CHECK(delivery_duration_seconds IS NULL OR delivery_duration_seconds>0)`); +} + const legacyTalentOutcomes = await (await database.prepare(` SELECT outcome.id,outcome.rule_id,outcome.variant_key,outcome.stat_attribute_id, outcome.effect_key,outcome.description_slot,outcome.sort_order diff --git a/src/lib/builder/loadout-combat.ts b/src/lib/builder/loadout-combat.ts index e31ee06..fe7cce7 100644 --- a/src/lib/builder/loadout-combat.ts +++ b/src/lib/builder/loadout-combat.ts @@ -455,7 +455,8 @@ function evaluateConfiguredMechanic( weaponBaseDamage: number, maxHealth: number, calculationMode: LoadoutDraft['calculationMode'], - equipmentContext = { protocolTalentUpgrades: 0 } + equipmentContext = { protocolTalentUpgrades: 0 }, + attributes: Record = {} ): ActiveMechanicEvaluation | null { if (calculationMode !== 'maximum' && state.mode === 'off') return null; const encounterSeconds = Math.max(1, Number(profile.encounterSeconds)); @@ -470,6 +471,7 @@ function evaluateConfiguredMechanic( maxHealth: Math.max(1, maxHealth), currentHealth: Math.max(0, maxHealth * Number(profile.healthPercent) / 100), weaponBaseDamage, + attributes, values: { extraHealth: profile.extraHealthActive ? 1 : 0 } }, equipmentContext @@ -818,7 +820,8 @@ function skillModifierWindows( Number(primarySelection.damage ?? 0), maxHealth, draft.calculationMode, - source.equipmentContext + source.equipmentContext, + baseAttributes ); return evaluation ? [{ ...source, evaluation }] : []; }); @@ -920,7 +923,8 @@ export function evaluateLoadoutCombat( Number(selection.damage), maxHealth, draft.calculationMode, - source.equipmentContext + source.equipmentContext, + criticalAttributes ); return evaluation ? [{ ...source, state, evaluation }] : []; }); diff --git a/src/lib/calculations/mechanic-editor.test.ts b/src/lib/calculations/mechanic-editor.test.ts index f89dcc1..f64be43 100644 --- a/src/lib/calculations/mechanic-editor.test.ts +++ b/src/lib/calculations/mechanic-editor.test.ts @@ -179,6 +179,90 @@ describe('conditional mechanic authoring payload', () => { ]); }); + it('accepts catalog Attribute references and rejects unknown IDs recursively', () => { + const referenced = draft(); + referenced.clauses[0].outcomes[0] = { + key: 'health-restoration', + label: 'Health Restoration', + phase: 'after_event', + kind: 'restoration', + resource: 'health', + scope: 'self', + basis: 'flat', + delivery: 'continuous', + durationSeconds: 3, + variants: { + I: { + value: 240, + expression: { + op: 'multiply', + left: 240, + right: { + op: 'divide', + left: { + ref: { + source: 'attribute', + attributeId: 'firepower-multiplier' + } + }, + right: 100 + } + } + }, + II: { + value: 336, + expression: { + op: 'multiply', + left: 336, + right: { + op: 'divide', + left: { + ref: { + source: 'attribute', + attributeId: 'firepower-multiplier' + } + }, + right: 100 + } + } + } + } + }; + const accepted = parseConditionalMechanicDraft(referenced, { + variantKeys: ['I', 'II'], + allowedAttributeIds: new Set(['firepower-multiplier']) + }); + expect(accepted.ok).toBe(true); + if (accepted.ok) { + expect(accepted.value.programs.I.clauses[0].outcomes?.[0].value).toMatchObject({ + right: { + left: { + ref: { + source: 'attribute', + attributeId: 'firepower-multiplier' + } + } + } + }); + expect(accepted.value.programs.II.clauses[0].outcomes?.[0]).toMatchObject({ + delivery: 'continuous', + durationSeconds: 3, + value: { left: 336 } + }); + } + + const rejected = parseConditionalMechanicDraft(referenced, { + variantKeys: ['I', 'II'], + allowedAttributeIds: new Set(['weapon-damage']) + }); + expect(rejected.ok).toBe(false); + if (!rejected.ok) { + expect(rejected.errors).toContain( + 'Conditional mechanic clauses item 1 outcomes item 1 variants I expression right left ref references unknown Attribute "firepower-multiplier".' + ); + } + }); + it('requires the exact configured variants and returns validation errors without throwing', () => { const invalid = draft(); invalid.clauses[0].outcomes[0].variants = { diff --git a/src/lib/calculations/mechanic-editor.ts b/src/lib/calculations/mechanic-editor.ts index 990e1a8..85f8aff 100644 --- a/src/lib/calculations/mechanic-editor.ts +++ b/src/lib/calculations/mechanic-editor.ts @@ -156,6 +156,8 @@ function isReference(value: unknown): value is ValueReference { && (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current'); case 'equipment': return typeof value.path === 'string' && Boolean(value.path.trim()); + case 'attribute': + return typeof value.attributeId === 'string' && Boolean(value.attributeId.trim()); case 'reducer': return true; case 'pool': @@ -167,6 +169,33 @@ function isReference(value: unknown): value is ValueReference { } } +function validateAttributeReferences( + value: unknown, + allowedAttributeIds: ReadonlySet | undefined, + errors: string[], + path = 'Conditional mechanic' +) { + if (Array.isArray(value)) { + value.forEach((item, index) => + validateAttributeReferences(item, allowedAttributeIds, errors, `${path} item ${index + 1}`)); + return; + } + if (!object(value)) return; + if ( + value.source === 'attribute' + && typeof value.attributeId === 'string' + && value.attributeId.trim() + && allowedAttributeIds + && !allowedAttributeIds.has(value.attributeId) + ) { + errors.push(`${path} references unknown Attribute "${value.attributeId}".`); + return; + } + for (const [key, nested] of Object.entries(value)) { + validateAttributeReferences(nested, allowedAttributeIds, errors, `${path} ${key}`); + } +} + function isExpression(value: unknown): value is NumericExpression { if (finite(value)) return true; if (!object(value)) return false; @@ -357,6 +386,19 @@ function parseOutcome( } else if (value.resource !== undefined) { errors.push(`${path} may only use a resource for restoration or resource cost.`); } + const delivery = value.delivery ?? 'instant'; + if (delivery !== 'instant' && delivery !== 'continuous') { + errors.push(`${path} has an invalid delivery mode.`); + } else if (delivery === 'continuous') { + if (kind !== 'restoration') { + errors.push(`${path} may only use continuous delivery for restoration.`); + } + if (!finite(value.durationSeconds) || value.durationSeconds <= 0) { + errors.push(`${path} continuous delivery requires a positive duration.`); + } + } else if (value.durationSeconds !== undefined) { + errors.push(`${path} may only set a duration for continuous delivery.`); + } if ( value.combatTiming !== undefined && value.combatTiming !== 'pre_event' @@ -547,6 +589,7 @@ export function parseConditionalMechanicDraft( pools, clauses }; + validateAttributeReferences(draft, options.allowedAttributeIds, errors); const inputRequiresAssumption = scenarioInputs.some( (input) => input.readiness === 'requires_assumption' ); diff --git a/src/lib/calculations/mechanic-persistence.test.ts b/src/lib/calculations/mechanic-persistence.test.ts index 9bb59dc..d00453e 100644 --- a/src/lib/calculations/mechanic-persistence.test.ts +++ b/src/lib/calculations/mechanic-persistence.test.ts @@ -148,6 +148,71 @@ describe('conditional-effect persistence adapter', () => { }] }] }); + expect(result.program?.clauses[0].outcomes?.[0]).not.toHaveProperty('delivery'); + expect(result.program?.clauses[0].outcomes?.[0]).not.toHaveProperty('durationSeconds'); + }); + + it('loads continuous restoration delivery metadata', () => { + const result = adaptConditionalEffectProgram({ + rule: rule(), + statePools: [], + clauses: [clause({ + trigger_id: null, + event_kind: 'enemy_killed', + parameters_json: '{}', + state_pool_id: null, + mutations_json: '[]' + })], + outcomes: [outcome({ + state_pool_id: null, + effect_key: 'predatory-restoration', + application_kind: 'restore_health', + effect_scope: 'self', + stat_attribute_id: null, + value: 240, + value_scaling: 'flat', + application_phase: 'after_event', + resource_kind: 'health', + delivery_mode: 'continuous', + delivery_duration_seconds: 3, + min_state_value: null + })], + triggers: [] + }, { name: 'Predatory Aura', variantKey: 'I' }); + + expect(result.errors).toEqual([]); + expect(result.program?.clauses[0].outcomes?.[0]).toMatchObject({ + kind: 'restoration', + resource: 'health', + delivery: 'continuous', + durationSeconds: 3 + }); + }); + + it('rejects incomplete continuous restoration metadata', () => { + const result = adaptConditionalEffectProgram({ + rule: rule(), + statePools: [], + clauses: [clause({ + trigger_id: null, + event_kind: 'enemy_killed', + parameters_json: '{}', + state_pool_id: null, + mutations_json: '[]' + })], + outcomes: [outcome({ + state_pool_id: null, + application_kind: 'restore_health', + stat_attribute_id: null, + resource_kind: 'health', + delivery_mode: 'continuous', + delivery_duration_seconds: null + })], + triggers: [] + }, { variantKey: 'I' }); + + expect(result.status).toBe('INVALID'); + expect(result.errors).toContain('Outcome 1 continuous delivery requires a positive duration.'); }); it('adapts the Happy Survival branches, explicit cadence, health timing, and upgrade references', () => { diff --git a/src/lib/calculations/mechanic-persistence.ts b/src/lib/calculations/mechanic-persistence.ts index f7f5efc..dffd4c3 100644 --- a/src/lib/calculations/mechanic-persistence.ts +++ b/src/lib/calculations/mechanic-persistence.ts @@ -82,6 +82,8 @@ export type ConditionalEffectOutcomeRow = { value_basis: DatabaseValue; resource_kind?: DatabaseValue; combat_timing?: DatabaseValue; + delivery_mode?: DatabaseValue; + delivery_duration_seconds?: DatabaseValue; value_scaling: DatabaseValue; unit: DatabaseValue; damage_basis: DatabaseValue; @@ -530,6 +532,17 @@ function parseOutcome( if (combatTiming && combatTiming !== 'pre_event' && combatTiming !== 'current') { errors.push(`${path} has invalid combat timing.`); } + const delivery = nullableText(row.delivery_mode) ?? 'instant'; + if (delivery !== 'instant' && delivery !== 'continuous') { + errors.push(`${path} has an unsupported delivery mode.`); + } + const durationSeconds = number(row.delivery_duration_seconds); + if (delivery === 'continuous' && (durationSeconds === null || durationSeconds <= 0)) { + errors.push(`${path} continuous delivery requires a positive duration.`); + } + if (delivery === 'instant' && durationSeconds !== null) { + errors.push(`${path} instant delivery cannot set a duration.`); + } return { clauseId: nullableText(row.clause_id), outcome: { @@ -546,6 +559,9 @@ function parseOutcome( ...(combatTiming === 'pre_event' || combatTiming === 'current' ? { combatTiming } : {}), + ...(delivery === 'continuous' && durationSeconds !== null + ? { delivery, durationSeconds } + : {}), ...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id) ? { attributeId: text(row.stat_attribute_id) } : {}), diff --git a/src/lib/calculations/mechanic.test.ts b/src/lib/calculations/mechanic.test.ts index 530a73d..312d4ee 100644 --- a/src/lib/calculations/mechanic.test.ts +++ b/src/lib/calculations/mechanic.test.ts @@ -784,6 +784,140 @@ describe('structured mechanic runtime', () => { expect(result.script).toContain('COUNT 3 CONSECUTIVE MATCHES PER EVENT.targetId'); expect(result.script).toContain('[RECIPE distinct-enemies-within-window]'); }); + it('resolves live Attribute percentages and delivers a restoration total continuously', () => { + const firepowerMultiplierId = '82a95611-283b-4fd8-a6ed-3567a316f36e'; + const program: MechanicProgram = { + id: 'predatory-aura', + name: 'Predatory Aura', + clauses: [{ + id: 'restore-on-kill', + when: 'enemy_killed', + reducer: { kind: 'none' }, + mutations: [], + outcomes: [{ + id: 'health-restoration', + label: 'Health restoration', + phase: 'after_event', + kind: 'restoration', + scope: 'self', + resource: 'health', + basis: 'flat', + delivery: 'continuous', + durationSeconds: 3, + value: { + op: 'multiply', + left: 240, + right: { + op: 'divide', + left: { ref: { source: 'attribute', attributeId: firepowerMultiplierId } }, + right: 100 + } + } + }] + }] + }; + const result = evaluateMechanicProgram( + program, + [{ kind: 'enemy_killed', at: 0 }], + custom(3), + { + maxHealth: 1_000, + currentHealth: 500, + attributes: { [firepowerMultiplierId]: 100 } + } + ); + expect(result.status).toBe('OK'); + expect(result.outcomes).toHaveLength(1); + expect(result.outcomes[0]).toMatchObject({ + at: 3, + delivery: 'continuous', + deliveryStart: 0, + deliveryEnd: 3, + totalResolvedValue: 240, + resolvedValue: 240, + appliedValue: 240 + }); + expect(result.finalCombat?.currentHealth).toBe(740); + expect(result.script).toContain( + 'ATTRIBUTE[82a95611-283b-4fd8-a6ed-3567a316f36e] / 100' + ); + expect(result.script).toContain('DELIVER TOTAL CONTINUOUSLY OVER 3s'); + + const missingAttribute = evaluateMechanicProgram( + program, + [{ kind: 'enemy_killed', at: 0 }], + custom(3), + { maxHealth: 1_000, currentHealth: 500 } + ); + expect(missingAttribute.status).toBe('INVALID'); + expect(missingAttribute.errors).toContain( + `This mechanic requires authoritative Attribute ${firepowerMultiplierId}.` + ); + }); + + it('integrates overlapping restoration streams at event boundaries without banking overheal', () => { + const program: MechanicProgram = { + id: 'continuous-restoration', + name: 'Continuous Restoration', + clauses: [{ + id: 'restore', + when: 'enemy_killed', + reducer: { kind: 'none' }, + mutations: [], + outcomes: [{ + id: 'restore-health', + label: 'Restore Health', + phase: 'after_event', + kind: 'restoration', + scope: 'self', + resource: 'health', + basis: 'flat', + value: 240, + delivery: 'continuous', + durationSeconds: 3 + }] + }, { + id: 'damage-between-streams', + when: 'agent_hit', + reducer: { kind: 'none' }, + mutations: [], + outcomes: [{ + id: 'health-cost', + label: 'Incoming damage proxy', + phase: 'on_event', + kind: 'resource_cost', + scope: 'self', + resource: 'health', + basis: 'flat', + value: 100 + }] + }] + }; + const result = evaluateMechanicProgram( + program, + [ + { kind: 'enemy_killed', at: 0 }, + { kind: 'agent_hit', at: 1 }, + { kind: 'enemy_killed', at: 1 } + ], + custom(4), + { maxHealth: 1_000, currentHealth: 500 } + ); + const continuous = result.outcomes.filter((outcome) => outcome.delivery === 'continuous'); + expect(continuous[0]).toMatchObject({ at: 1, resolvedValue: 80, appliedValue: 80 }); + expect(continuous.reduce((total, outcome) => total + outcome.resolvedValue, 0)).toBeCloseTo(480); + expect(continuous.reduce((total, outcome) => total + outcome.appliedValue, 0)).toBeCloseTo(480); + expect(result.finalCombat?.currentHealth).toBe(880); + + const capped = evaluateMechanicProgram( + program, + [{ kind: 'enemy_killed', at: 0 }], + custom(3), + { maxHealth: 1_000, currentHealth: 950 } + ); + expect(capped.outcomes.reduce((total, outcome) => total + outcome.appliedValue, 0)).toBe(50); + expect(capped.finalCombat?.currentHealth).toBe(1_000); + }); }); describe('mechanic validation and pseudo-script', () => { @@ -866,6 +1000,37 @@ describe('mechanic validation and pseudo-script', () => { } }); + it('rejects invalid continuous-delivery combinations', () => { + const base = { + id: 'delivery-validation', + name: 'Delivery Validation', + clauses: [{ + id: 'invalid-delivery', + when: 'enemy_killed' as const, + reducer: { kind: 'none' as const }, + mutations: [], + outcomes: [{ + id: 'damage-over-time', + label: 'Damage', + phase: 'after_event' as const, + kind: 'direct_damage' as const, + scope: 'target' as const, + basis: 'flat' as const, + value: 10, + delivery: 'continuous' as const, + durationSeconds: 3 + }] + }] + }; + const result = validateMechanicProgram(base); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain( + 'Clause 1 outcome 1 can only use continuous delivery for restoration.' + ); + } + }); + it('formats nested conditions and state operations deterministically', () => { const program: MechanicProgram = { id: 'format', diff --git a/src/lib/calculations/mechanic.ts b/src/lib/calculations/mechanic.ts index 375c7c2..605f09b 100644 --- a/src/lib/calculations/mechanic.ts +++ b/src/lib/calculations/mechanic.ts @@ -164,6 +164,7 @@ export type ValueReference = | { source: 'event'; path: string } | { source: 'scenario'; key: string } | { source: 'combat'; path: string; timing?: 'pre_event' | 'current' } + | { source: 'attribute'; attributeId: string } | { source: 'equipment'; path: string } | { source: 'reducer' } | { source: 'pool'; pool: string; entity?: EntitySelector }; @@ -263,6 +264,8 @@ export type MechanicOutcome = { value: NumericExpression; basis: OutcomeBasis; resource?: MechanicResource; + delivery?: 'instant' | 'continuous'; + durationSeconds?: number; combatTiming?: 'pre_event' | 'current'; attributeId?: string; statePool?: string; @@ -337,6 +340,10 @@ export type MechanicOutcomeEmission = { stateValue: number; combatBefore: MechanicCombatContext | null; combatAfter: MechanicCombatContext | null; + delivery: 'instant' | 'continuous'; + deliveryStart: number | null; + deliveryEnd: number | null; + totalResolvedValue: number; }; export type MechanicEvaluation = { @@ -366,6 +373,7 @@ export type MechanicCombatContext = { maxAmmo?: number; currentAmmo?: number; weaponBaseDamage?: number; + attributes?: Record; values?: Record; }; @@ -389,6 +397,24 @@ type EvaluationContext = { preEventCombat: MechanicCombatContext | null; equipment: MechanicEquipmentContext | null; }; + +type ContinuousDelivery = { + clauseId: string; + outcomeId: string; + phase: OutcomePhase; + scope: MechanicOutcomeScope; + resource: MechanicResource; + attributeId: string | null; + basis: OutcomeBasis; + combatTiming: 'pre_event' | 'current'; + value: number; + totalResolvedValue: number; + weight: number; + stateValue: number; + start: number; + end: number; + appliedThrough: number; +}; type ReducerState = { count: number; timestamps: number[]; @@ -459,6 +485,8 @@ function resolveReference(reference: ValueReference, context: EvaluationContext) const combat = reference.timing === 'pre_event' ? context.preEventCombat : context.combat; return getPath(combat, reference.path); } + case 'attribute': + return context.combat?.attributes?.[reference.attributeId]; case 'equipment': return getPath(context.equipment, reference.path); case 'reducer': @@ -805,6 +833,7 @@ function phaseMatches( function copyCombat(combat: MechanicCombatContext | null): MechanicCombatContext | null { return combat ? { ...combat, + attributes: combat.attributes ? { ...combat.attributes } : undefined, values: combat.values ? { ...combat.values } : undefined } : null; } @@ -862,7 +891,8 @@ function emitOutcomes( at: number, weight: number, before: Map, - context: EvaluationContext + context: EvaluationContext, + continuousDeliveries?: ContinuousDelivery[] ): MechanicOutcomeEmission[] { const emissions: MechanicOutcomeEmission[] = []; const eligible = (clause.outcomes ?? []).filter((outcome) => { @@ -890,6 +920,27 @@ function emitOutcomes( ? outcomeResource(outcome) : null; const targetResource = context.combat && resource ? combatResource(context.combat, resource) : null; + if (outcome.kind === 'restoration' && outcome.delivery === 'continuous') { + const durationSeconds = outcome.durationSeconds as number; + continuousDeliveries?.push({ + clauseId: clause.id, + outcomeId: outcome.id, + phase: outcome.phase, + scope: outcome.scope, + resource: outcomeResource(outcome), + attributeId: outcome.attributeId ?? null, + basis: outcome.basis, + combatTiming: outcome.combatTiming ?? 'current', + value, + totalResolvedValue: Math.max(0, resolvedValue * weight), + weight, + stateValue: current, + start: at, + end: at + durationSeconds, + appliedThrough: at + }); + continue; + } if (outcome.kind === 'resource_cost' && targetResource) { appliedValue = Math.min(targetResource.current, Math.max(0, appliedValue)); targetResource.setCurrent(targetResource.current - appliedValue); @@ -919,12 +970,71 @@ function emitOutcomes( weight, stateValue: current, combatBefore, - combatAfter: copyCombat(context.combat) + combatAfter: copyCombat(context.combat), + delivery: 'instant', + deliveryStart: null, + deliveryEnd: null, + totalResolvedValue: resolvedValue * weight }); } return emissions; } +function advanceContinuousDeliveries( + at: number, + deliveries: ContinuousDelivery[], + combat: MechanicCombatContext | null +): MechanicOutcomeEmission[] { + const emissions: MechanicOutcomeEmission[] = []; + for (const delivery of deliveries) { + const through = Math.min(at, delivery.end); + const elapsed = through - delivery.appliedThrough; + if (elapsed <= EPSILON) continue; + const duration = delivery.end - delivery.start; + const resolvedValue = delivery.totalResolvedValue * elapsed / duration; + const combatBefore = copyCombat(combat); + const targetResource = combat ? combatResource(combat, delivery.resource) : null; + let appliedValue = 0; + if (targetResource) { + appliedValue = Math.min( + targetResource.maximum - targetResource.current, + Math.max(0, resolvedValue) + ); + targetResource.setCurrent(targetResource.current + appliedValue); + } + delivery.appliedThrough = through; + emissions.push({ + clauseId: delivery.clauseId, + outcomeId: delivery.outcomeId, + at: through, + phase: delivery.phase, + kind: 'restoration', + scope: delivery.scope, + resource: delivery.resource, + attributeId: delivery.attributeId, + basis: delivery.basis, + combatTiming: delivery.combatTiming, + value: delivery.value, + resolvedValue, + appliedValue, + weight: delivery.weight, + stateValue: delivery.stateValue, + combatBefore, + combatAfter: copyCombat(combat), + delivery: 'continuous', + deliveryStart: delivery.start, + deliveryEnd: delivery.end, + totalResolvedValue: delivery.totalResolvedValue + }); + } + for (let index = deliveries.length - 1; index >= 0; index -= 1) { + if (deliveries[index].appliedThrough >= deliveries[index].end - EPSILON) { + deliveries.splice(index, 1); + } + } + return emissions; +} + function expirePools( at: number, clauses: MechanicClause[], @@ -932,7 +1042,8 @@ function expirePools( pools: Map, definitions: Map, combat: MechanicCombatContext | null, - equipment: MechanicEquipmentContext | null + equipment: MechanicEquipmentContext | null, + continuousDeliveries: ContinuousDelivery[] ): MechanicOutcomeEmission[] { const expired = [...pools.entries()] .filter(([, state]) => state.expiresAt !== null && state.expiresAt <= at + EPSILON) @@ -943,6 +1054,7 @@ function expirePools( let index = 0; while (index < expired.length) { const expiryAt = expired[index][1].expiresAt as number; + emissions.push(...advanceContinuousDeliveries(expiryAt, continuousDeliveries, combat)); const group: typeof expired = []; while ( index < expired.length @@ -987,7 +1099,8 @@ function expirePools( expiryAt, 1, before, - context + context, + continuousDeliveries )); } } @@ -1048,6 +1161,7 @@ function validateReference(reference: ValueReference, pools: Set, inputs } if (reference.source === 'event' && !reference.path.trim()) errors.push(`${path} requires an event path.`); if (reference.source === 'combat' && !reference.path.trim()) errors.push(`${path} requires a combat path.`); + if (reference.source === 'attribute' && !reference.attributeId.trim()) errors.push(`${path} requires an Attribute ID.`); if (reference.source === 'equipment' && !reference.path.trim()) errors.push(`${path} requires an equipment path.`); } @@ -1158,6 +1272,20 @@ function programCombatRequirements(program: MechanicProgram) { let required = false; let weaponBaseDamage = false; let equipment = false; + const attributeIds = new Set(); + const visit = (value: unknown) => { + if (!value || typeof value !== 'object') return; + if ( + 'source' in value + && value.source === 'attribute' + && 'attributeId' in value + && typeof value.attributeId === 'string' + ) { + attributeIds.add(value.attributeId); + } + for (const child of Object.values(value)) visit(child); + }; + visit(program); for (const clause of program.clauses) { if (clause.if && predicateUsesCombat(clause.if)) required = true; if (clause.if && predicateUsesEquipment(clause.if)) equipment = true; @@ -1177,7 +1305,8 @@ function programCombatRequirements(program: MechanicProgram) { if (outcome.basis === 'weapon_base_percent') weaponBaseDamage = true; } } - return { required, weaponBaseDamage, equipment }; + if (attributeIds.size) required = true; + return { required, weaponBaseDamage, equipment, attributeIds }; } export function validateMechanicProgram(program: MechanicProgram): ValidationResult { @@ -1353,6 +1482,19 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes if (outcome.resource !== undefined && outcome.kind !== 'restoration' && outcome.kind !== 'resource_cost') { errors.push(`${outcomePath} can only set a resource for restoration or resource-cost outcomes.`); } + const delivery = outcome.delivery ?? 'instant'; + if (!['instant', 'continuous'].includes(delivery)) { + errors.push(`${outcomePath} has an unsupported delivery mode.`); + } else if (delivery === 'continuous') { + if (outcome.kind !== 'restoration') { + errors.push(`${outcomePath} can only use continuous delivery for restoration.`); + } + if (!finiteNumber(outcome.durationSeconds) || (outcome.durationSeconds ?? 0) <= 0) { + errors.push(`${outcomePath} continuous delivery duration must be greater than 0 seconds.`); + } + } else if (outcome.durationSeconds !== undefined) { + errors.push(`${outcomePath} can only set a duration for continuous delivery.`); + } const allowedBasis: Record = { attribute_modifier: ['flat'], direct_damage: ['flat', 'max_health_percent', 'current_health_percent', 'weapon_base_percent'], @@ -1439,6 +1581,11 @@ export function evaluateMechanicProgram( if (combatRequirements.weaponBaseDamage && combatContext.weaponBaseDamage === undefined) { scenarioErrors.push('This mechanic requires authoritative weapon base damage.'); } + for (const attributeId of combatRequirements.attributeIds) { + if (!finiteNumber(combatContext.attributes?.[attributeId])) { + scenarioErrors.push(`This mechanic requires authoritative Attribute ${attributeId}.`); + } + } } if (combatRequirements.equipment && !equipmentContext) { scenarioErrors.push('This mechanic requires authoritative equipment context.'); @@ -1472,6 +1619,7 @@ export function evaluateMechanicProgram( const cooldowns = new Map(); const emissions: MechanicEmission[] = []; const outcomes: MechanicOutcomeEmission[] = []; + const continuousDeliveries: ContinuousDelivery[] = []; if (scenario.mode === 'maximum') { for (const clause of program.clauses) { const event: MechanicEvent = { @@ -1497,7 +1645,7 @@ export function evaluateMechanicProgram( outcomes.push(...emitOutcomes({ ...clause, outcomes: (clause.outcomes ?? []).filter((outcome) => outcome.phase === 'while_active') - }, 'after', 0, 1, before, context)); + }, 'after', 0, 1, before, context, continuousDeliveries)); } } const ordered = events @@ -1514,8 +1662,10 @@ export function evaluateMechanicProgram( pools, poolDefinitions, combat, - equipment + equipment, + continuousDeliveries )); + outcomes.push(...advanceContinuousDeliveries(event.at, continuousDeliveries, combat)); const preEventCombat = copyCombat(combat); for (const clause of program.clauses) { if (clause.when !== event.kind) continue; @@ -1544,7 +1694,15 @@ export function evaluateMechanicProgram( .map((outcome) => outcome.statePool) .filter((pool): pool is string => Boolean(pool))); const before = new Map([...relevantPools].map((pool) => [pool, stateValue(pool, undefined, context)])); - outcomes.push(...emitOutcomes(clause, 'before', reduction.at, weight, before, context)); + outcomes.push(...emitOutcomes( + clause, + 'before', + reduction.at, + weight, + before, + context, + continuousDeliveries + )); const mutationRecords = clause.mutations.map((mutation) => applyMutation(mutation, reduction.at, context, weight)); emissions.push({ @@ -1557,7 +1715,15 @@ export function evaluateMechanicProgram( if (clause.cooldownSeconds) { cooldowns.set(stateKey, reduction.at + clause.cooldownSeconds); } - outcomes.push(...emitOutcomes(clause, 'after', reduction.at, weight, before, context)); + outcomes.push(...emitOutcomes( + clause, + 'after', + reduction.at, + weight, + before, + context, + continuousDeliveries + )); } } } @@ -1568,7 +1734,13 @@ export function evaluateMechanicProgram( pools, poolDefinitions, combat, - equipment + equipment, + continuousDeliveries + )); + outcomes.push(...advanceContinuousDeliveries( + scenario.encounterSeconds, + continuousDeliveries, + combat )); return { status: 'OK', @@ -1613,6 +1785,8 @@ function formatReference(reference: ValueReference): string { return `SCENARIO.${reference.key}`; case 'combat': return `${reference.timing === 'pre_event' ? 'PRE-EVENT ' : ''}COMBAT.${reference.path}`; + case 'attribute': + return `ATTRIBUTE[${reference.attributeId}]`; case 'equipment': return `EQUIPMENT.${reference.path}`; case 'reducer': @@ -1717,7 +1891,10 @@ function formatOutcome(outcome: MechanicOutcome): string { const timing = outcome.basis === 'current_health_percent' ? ` USING ${(outcome.combatTiming ?? 'current') === 'pre_event' ? 'PRE-EVENT' : 'CURRENT'} HEALTH` : ''; - return `${outcome.phase.toUpperCase()}: APPLY ${formatExpression(outcome.value)} ${outcome.basis.toUpperCase()}${resource}${attribute}${perStack}${stackBand}${timing} // ${outcome.label}`; + const delivery = outcome.delivery === 'continuous' + ? `; DELIVER TOTAL CONTINUOUSLY OVER ${formatNumber(outcome.durationSeconds ?? 0)}s` + : ''; + return `${outcome.phase.toUpperCase()}: APPLY ${formatExpression(outcome.value)} ${outcome.basis.toUpperCase()}${resource}${attribute}${perStack}${stackBand}${timing}${delivery} // ${outcome.label}`; } export function formatMechanicProgram(program: MechanicProgram): string { diff --git a/src/lib/components/SearchableAssignment.svelte b/src/lib/components/SearchableAssignment.svelte index e7192a6..d66f1de 100644 --- a/src/lib/components/SearchableAssignment.svelte +++ b/src/lib/components/SearchableAssignment.svelte @@ -12,7 +12,8 @@ subjectLabel = 'TALENTS', searchLabel = `FILTER AVAILABLE ${subjectLabel}`, searchPlaceholder = `Search ${subjectLabel.toLocaleLowerCase()}…`, - emptySelectionLabel = `NO ${subjectLabel} ASSIGNED` + emptySelectionLabel = `NO ${subjectLabel} ASSIGNED`, + onvaluechange }: { items: Item[]; selected?: string[]; @@ -26,6 +27,7 @@ searchLabel?: string; searchPlaceholder?: string; emptySelectionLabel?: string; + onvaluechange?: (value: string) => void; } = $props(); let query = $state(''); let open = $state(false); @@ -42,6 +44,7 @@ const toggle = (id: string) => { if (mode === 'single') { value = value === id ? '' : id; + onvaluechange?.(value); if (compact) { open = false; query = ''; diff --git a/src/lib/components/isac/MechanicProgramEditor.svelte b/src/lib/components/isac/MechanicProgramEditor.svelte index ce5e9f3..115cda0 100644 --- a/src/lib/components/isac/MechanicProgramEditor.svelte +++ b/src/lib/components/isac/MechanicProgramEditor.svelte @@ -60,6 +60,7 @@ let recipeWindow = $state(3); let recipeDistance = $state(10); let replaceConfirmation = $state(false); + let valueModelOverrides = $state>({}); const readinessOptions = [ { value: 'ready', label: 'READY' }, @@ -173,7 +174,7 @@ kind: 'attribute_modifier', scope: 'self', basis: 'flat', - perStack: true, + perStack: false, descriptionSlot: index + 1, attributeId: '', variants: Object.fromEntries(valueColumns.map((column) => [ @@ -233,6 +234,7 @@ type OutcomeValueModel = | 'static' | 'event' + | 'attribute' | 'reducer' | 'scenario' | 'protocol_upgrades' @@ -241,6 +243,7 @@ const valueModelOptions = $derived([ { value: 'static', label: 'STATIC' }, { value: 'event', label: 'EVENT FIELD' }, + { value: 'attribute', label: 'ATTRIBUTE ÷ 100' }, { value: 'reducer', label: 'REDUCER' }, { value: 'scenario', label: 'SCENARIO' }, ...(allowProtocolUpgradeScaling @@ -283,7 +286,20 @@ }; } + function attributePercentExpression(value: number, attributeId: string): NumericExpression { + return { + op: 'multiply', + left: value, + right: { + op: 'divide', + left: { ref: { source: 'attribute', attributeId } }, + right: 100 + } + }; + } + function outcomeValueModel(outcome: ConditionalMechanicOutcomeDraft): OutcomeValueModel { + if (valueModelOverrides[outcome.key]) return valueModelOverrides[outcome.key]; const expression = Object.values(outcome.variants)[0]?.expression; if (expression === undefined) return 'static'; if ( @@ -298,6 +314,14 @@ if (expression.right.ref.source === 'scenario') return 'scenario'; if (expression.right.ref.source === 'reducer') return 'reducer'; } + if ( + 'op' in expression.right + && expression.right.op === 'divide' + && typeof expression.right.left === 'object' + && 'ref' in expression.right.left + && expression.right.left.ref.source === 'attribute' + && expression.right.right === 100 + ) return 'attribute'; if ( 'op' in expression.right && expression.right.op === 'add' @@ -330,12 +354,32 @@ return source === 'event' ? 'values.enemyCount' : rule.scenarioInputs[0]?.key ?? ''; } + function outcomeAttributeReference(outcome: ConditionalMechanicOutcomeDraft) { + const expression = Object.values(outcome.variants)[0]?.expression; + if ( + expression + && typeof expression === 'object' + && 'op' in expression + && expression.op === 'multiply' + && typeof expression.right === 'object' + && 'op' in expression.right + && expression.right.op === 'divide' + && typeof expression.right.left === 'object' + && 'ref' in expression.right.left + && expression.right.left.ref.source === 'attribute' + ) return expression.right.left.ref.attributeId; + return ''; + } + function expressionForModel( model: OutcomeValueModel, value: number, reference = '' ): NumericExpression | undefined { if (model === 'event') return referenceExpression('event', reference || 'values.enemyCount', value); + if (model === 'attribute') return reference + ? attributePercentExpression(value, reference) + : undefined; if (model === 'scenario') return reference ? referenceExpression('scenario', reference, value) : undefined; @@ -353,6 +397,10 @@ reference = '' ) { const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex]; + const nextOverrides = { ...valueModelOverrides }; + if (model === 'attribute' && !reference) nextOverrides[outcome.key] = 'attribute'; + else delete nextOverrides[outcome.key]; + valueModelOverrides = nextOverrides; const variants = Object.fromEntries(Object.entries(outcome.variants).map(([key, variant]) => { const expression = expressionForModel(model, Number(variant.value), reference); return [key, expression === undefined ? { value: Number(variant.value) } : { @@ -383,6 +431,17 @@ expression as { right: { ref: { key: string } } } ).right.ref.key, value); } + if (model === 'attribute') { + return attributePercentExpression(value, outcomeAttributeReference({ + key: 'temporary', + label: 'Temporary', + phase: 'on_event', + kind: 'marker', + scope: 'self', + basis: 'flat', + variants: { base: { value, expression } } + })); + } if (model === 'reducer') { return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } }; } @@ -633,7 +692,7 @@
- updateOutcome(clauseIndex,outcomeIndex,{kind:values[0] as ConditionalMechanicOutcomeDraft['kind'],resource:values[0] === 'restoration' || values[0] === 'resource_cost' ? outcome.resource ?? 'health' : undefined,attributeId:values[0] === 'attribute_modifier' ? outcome.attributeId ?? '' : undefined})}/> + updateOutcome(clauseIndex,outcomeIndex,{kind:values[0] as ConditionalMechanicOutcomeDraft['kind'],resource:values[0] === 'restoration' || values[0] === 'resource_cost' ? outcome.resource ?? 'health' : undefined,attributeId:values[0] === 'attribute_modifier' ? outcome.attributeId ?? '' : undefined,delivery:values[0] === 'restoration' ? outcome.delivery : undefined,durationSeconds:values[0] === 'restoration' ? outcome.durationSeconds : undefined})}/>
4 ? 4 : scopeOptions.length} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{scope:values[0] as MechanicOutcomeScope})}/> @@ -642,6 +701,23 @@ {:else if outcome.kind === 'restoration' || outcome.kind === 'resource_cost'} updateOutcome(clauseIndex,outcomeIndex,{resource:values[0] as 'health'|'armor'|'ammo'})}/> {/if} + {#if outcome.kind === 'restoration'} +
+ updateOutcome(clauseIndex,outcomeIndex,{ + delivery: values[0] === 'continuous' ? 'continuous' : undefined, + durationSeconds: values[0] === 'continuous' ? outcome.durationSeconds ?? 3 : undefined + })} + /> + {#if outcome.delivery === 'continuous'} + + {/if} +
+ {/if} {#if outcome.kind === 'direct_damage' || outcome.kind === 'restoration' || outcome.kind === 'resource_cost'} updateOutcome(clauseIndex,outcomeIndex,{basis:values[0] as OutcomeBasis})}/> {/if} @@ -671,6 +747,15 @@ columns={Math.min(4,rule.scenarioInputs.length)} onchange={(values) => setOutcomeValueModel(clauseIndex,outcomeIndex,'scenario',values[0])} /> + {:else if outcomeValueModel(outcome) === 'attribute'} + setOutcomeValueModel(clauseIndex,outcomeIndex,'attribute',value)} + /> {/if} {#if rule.pools.length}
@@ -743,13 +828,14 @@ .outcome-grid{display:grid;grid-template-columns:minmax(200px,1fr) minmax(360px,1.4fr) minmax(120px,.3fr);align-items:end;gap:8px} .variant-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px} .state-scaling{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr) repeat(2,minmax(110px,.3fr));align-items:end;gap:8px} + .delivery-controls{display:grid;grid-template-columns:minmax(260px,1fr) minmax(150px,.35fr);align-items:end;gap:8px}.delivery-controls>.field{margin:0} .outcome-footer{display:flex;align-items:end;justify-content:space-between;gap:8px} .remove,.danger-quiet{min-height:38px;padding:0 10px;border:1px solid var(--shd-danger);background:transparent;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer} .remove:hover,.remove:focus-visible,.danger-quiet:hover,.danger-quiet:focus-visible{background:var(--shd-danger);color:var(--shd-surface)} .compact-action{min-height:36px}.add-branch{min-height:44px}.empty-state{padding:10px}.script-preview{display:grid;grid-template-columns:minmax(190px,.28fr) minmax(0,1fr);gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-border);border-top:2px solid var(--shd-success);background:var(--shd-surface)} .script-preview>div{display:grid;align-content:start;gap:3px}.script-preview>div span{color:var(--shd-success);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px} @media(max-width:1300px){.scenario-row{grid-template-columns:repeat(2,minmax(0,1fr))}.scenario-row>.description-field{grid-column:1/-1}} - @media(max-width:1000px){.recipe-grid,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.script-preview{grid-template-columns:1fr 1fr}.recipe-grid>:first-child,.script-preview>:last-child{grid-column:1/-1}.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:1/-1}} - @media(max-width:620px){.mechanic-heading,.recipe-panel>header,.scenario-panel>header,.state-panel>header,.branch>header,.outcome-heading,.replace-confirm,.outcome-footer{align-items:stretch;flex-direction:column}.recipe-grid,.scenario-row,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.script-preview{grid-template-columns:1fr}.recipe-grid>:first-child,.script-preview>:last-child,.scenario-row>.description-field,.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:auto}.branch>header>div{align-items:stretch;flex-direction:column}.branch>header .field{min-width:0}.remove,.danger-quiet,.compact-action,.add-branch{width:100%;min-height:44px}} + @media(max-width:1000px){.recipe-grid,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.delivery-controls,.script-preview{grid-template-columns:1fr 1fr}.recipe-grid>:first-child,.script-preview>:last-child{grid-column:1/-1}.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:1/-1}} + @media(max-width:620px){.mechanic-heading,.recipe-panel>header,.scenario-panel>header,.state-panel>header,.branch>header,.outcome-heading,.replace-confirm,.outcome-footer{align-items:stretch;flex-direction:column}.recipe-grid,.scenario-row,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.delivery-controls,.script-preview{grid-template-columns:1fr}.recipe-grid>:first-child,.script-preview>:last-child,.scenario-row>.description-field,.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:auto}.branch>header>div{align-items:stretch;flex-direction:column}.branch>header .field{min-width:0}.remove,.danger-quiet,.compact-action,.add-branch{width:100%;min-height:44px}} @media(prefers-reduced-motion:reduce){.remove,.danger-quiet{transition:none}} diff --git a/src/lib/server/ai/mechanic-authoring-prompt.test.ts b/src/lib/server/ai/mechanic-authoring-prompt.test.ts new file mode 100644 index 0000000..2a514cb --- /dev/null +++ b/src/lib/server/ai/mechanic-authoring-prompt.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import type { AiMechanicAuthoringRequest } from '$lib/ai/mechanic-authoring'; +import { + AI_MECHANIC_AUTHORING_INSTRUCTIONS, + buildAiMechanicAuthoringInput +} from './mechanic-authoring-prompt'; + +const request: AiMechanicAuthoringRequest = { + context: { + sourceKind: 'talent', + sourceName: 'Predatory Aura', + sourceDescription: 'Restore Health based on the current Firepower Multiplier.' + }, + currentDraft: { + version: 1, + simulationReadiness: 'captured_only', + scenarioInputs: [], + pools: [], + clauses: [] + }, + valueColumns: [ + { key: 'tierI', variantKey: 'I', label: 'TIER I' }, + { key: 'tierII', variantKey: 'II', label: 'TIER II' } + ], + allowAssignedAbility: false, + allowProtocolUpgradeScaling: false, + transcript: [] +}; + +describe('AI mechanic authoring prompt', () => { + it('advertises exact catalog Attribute references and displayed-percent semantics', () => { + const input = JSON.parse(buildAiMechanicAuthoringInput({ + request, + attributes: [{ + id: 'firepower-multiplier', + name: 'Firepower Multiplier', + description: null, + unit: '%', + type: null, + category: null, + calculationType: null + }], + events: [], + actions: [], + recipeKeys: [] + })); + + expect(input.canonicalCatalog.grammar.valueReferences).toContain('attribute'); + expect(input.canonicalCatalog.grammar.referenceShapes).toContainEqual({ + ref: { + source: 'attribute', + attributeId: 'replace-with-an-exact-catalog-attribute-id' + } + }); + expect(input.examplesAndRules).toEqual(expect.arrayContaining([ + expect.stringContaining('exact supplied catalog ID'), + expect.stringContaining('Divide that reference by 100') + ])); + expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain('Never invent an Attribute ID'); + }); +}); diff --git a/src/lib/server/ai/mechanic-authoring-prompt.ts b/src/lib/server/ai/mechanic-authoring-prompt.ts index b8980b9..97709fa 100644 --- a/src/lib/server/ai/mechanic-authoring-prompt.ts +++ b/src/lib/server/ai/mechanic-authoring-prompt.ts @@ -1,7 +1,7 @@ import type { AiMechanicAuthoringRequest } from '$lib/ai/mechanic-authoring'; import type { AiMechanicAttribute } from './mechanic-authoring-context'; -export const AI_MECHANIC_PROMPT_VERSION = 2; +export const AI_MECHANIC_PROMPT_VERSION = 3; const mechanicsGrammar = { version: 1, @@ -39,13 +39,14 @@ const mechanicsGrammar = { ], mutationKinds: ['none', 'add', 'set', 'consume', 'derive', 'clear', 'transfer'], entitySelectors: ['agent', 'event_actor', 'event_target', 'event_field'], - valueReferences: ['event', 'scenario', 'combat', 'equipment', 'reducer', 'pool'], + valueReferences: ['event', 'scenario', 'combat', 'equipment', 'attribute', 'reducer', 'pool'], numericExpressionOperators: ['add', 'subtract', 'multiply', 'divide', 'min', 'max', 'clamp'], referenceShapes: [ { ref: { source: 'event', path: 'values.distanceMeters' } }, { ref: { source: 'scenario', key: 'input-key' } }, { ref: { source: 'combat', path: 'currentHealth', timing: 'current' } }, { ref: { source: 'equipment', path: 'protocolTalentUpgrades' } }, + { ref: { source: 'attribute', attributeId: 'replace-with-an-exact-catalog-attribute-id' } }, { ref: { source: 'reducer' } }, { ref: { source: 'pool', pool: 'pool-key', entity: { kind: 'agent' } } } ], @@ -230,6 +231,8 @@ export function buildAiMechanicAuthoringInput(input: { }, examplesAndRules: [ 'Percent values use the numeric percentage shown in game, such as 3 for +3%, rather than 0.03.', + 'Live Attribute references must use {source:"attribute",attributeId:""}. Never use an Attribute name or invent an ID.', + '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%.', '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 9653521..92c45b5 100644 --- a/src/lib/server/conditional-effects.test.ts +++ b/src/lib/server/conditional-effects.test.ts @@ -1,5 +1,10 @@ +import { connect } from '@tursodatabase/database'; import { describe, expect, it } from 'vitest'; -import { conditionalRuleDraftFromRows } from './conditional-effects.js'; +import { + conditionalRuleDraftFromRows, + loadConditionalRuleDraft, + saveConditionalRuleInTransaction +} from './conditional-effects.js'; const rule = { id: 'rule-1', @@ -72,5 +77,157 @@ describe('conditional mechanic persistence adapter', () => { I: { value: 3 }, II: { value: 5 } }); + expect(draft?.clauses[0].outcomes[0]).not.toHaveProperty('delivery'); + expect(draft?.clauses[0].outcomes[0]).not.toHaveProperty('durationSeconds'); + }); + + it('loads continuous delivery metadata and normalizes legacy instant variants', () => { + const continuous = conditionalRuleDraftFromRows( + rule, + [], + [clause], + [ + { + ...outcome, + application_kind: 'restore_health', + stat_attribute_id: null, + resource_kind: 'health', + delivery_mode: 'continuous', + delivery_duration_seconds: 3, + variant_key: 'I' + }, + { + ...outcome, + application_kind: 'restore_health', + stat_attribute_id: null, + resource_kind: 'health', + delivery_mode: 'continuous', + delivery_duration_seconds: 3, + variant_key: 'II' + } + ], + ['I', 'II'] + ); + expect(continuous?.clauses[0].outcomes[0]).toMatchObject({ + delivery: 'continuous', + durationSeconds: 3 + }); + + expect(() => conditionalRuleDraftFromRows( + rule, + [], + [clause], + [ + { ...outcome, variant_key: 'I' }, + { ...outcome, variant_key: 'II', delivery_mode: 'instant' } + ], + ['I', 'II'] + )).not.toThrow(); + }); + + it('rejects divergent continuous-delivery durations across variants', () => { + expect(() => conditionalRuleDraftFromRows( + rule, + [], + [clause], + [ + { + ...outcome, + variant_key: 'I', + delivery_mode: 'continuous', + delivery_duration_seconds: 3 + }, + { + ...outcome, + variant_key: 'II', + delivery_mode: 'continuous', + delivery_duration_seconds: 4 + } + ], + ['I', 'II'] + )).toThrow(/inconsistent delivery_duration_seconds metadata/); + }); + + it('round-trips continuous delivery through normalized rows', async () => { + const database = await connect(':memory:'); + await database.exec(`CREATE TABLE conditional_effect_rules ( + id TEXT PRIMARY KEY, source_type TEXT, source_id TEXT, configured INTEGER, + script_version INTEGER, simulation_readiness TEXT, scenario_inputs_json TEXT, + created_at TEXT, updated_at TEXT, UNIQUE(source_type,source_id) + )`); + await database.exec(`CREATE TABLE conditional_effect_state_pools ( + id TEXT PRIMARY KEY, rule_id TEXT, state_key TEXT, label TEXT, state_kind TEXT, + state_scope TEXT, initial_value REAL, minimum_value REAL, maximum_value REAL, + best_case_value REAL, duration_seconds REAL, expiry_policy TEXT, refresh_policy TEXT, + parameters_json TEXT, sort_order INTEGER, created_at TEXT, updated_at TEXT + )`); + await database.exec(`CREATE TABLE conditional_effect_clauses ( + id TEXT PRIMARY KEY, rule_id TEXT, clause_key TEXT, label TEXT, event_kind TEXT, + recipe_key TEXT, recipe_parameters_json TEXT, conditions_json TEXT, reducer_json TEXT, + weight_expression_json TEXT, emission_kind TEXT, state_pool_id TEXT, mutations_json TEXT, + cooldown_seconds REAL, output_branch TEXT, sort_order INTEGER, active INTEGER, + created_at TEXT, updated_at TEXT + )`); + await database.exec(`CREATE TABLE conditional_effect_outcomes ( + id TEXT PRIMARY KEY, rule_id TEXT, clause_id TEXT, state_pool_id TEXT, effect_key TEXT, + variant_key TEXT, outcome_key TEXT, application_kind TEXT, effect_scope TEXT, + stat_attribute_id TEXT, value REAL, value_expression_json TEXT, value_basis TEXT, + value_scaling TEXT, unit TEXT, damage_basis TEXT, application_phase TEXT, + combat_timing TEXT, resource_kind TEXT, delivery_mode TEXT, + delivery_duration_seconds REAL, min_state_value REAL, max_state_value REAL, + description_slot INTEGER, sort_order INTEGER, created_at TEXT, updated_at TEXT + )`); + + await saveConditionalRuleInTransaction(database, 'talent', 'predatory-aura', { + version: 1, + simulationReadiness: 'ready', + scenarioInputs: [], + pools: [], + clauses: [{ + key: 'weapon-kill', + label: 'Weapon kill', + event: 'enemy_killed', + if: { kind: 'true' }, + reducer: { kind: 'none' }, + mutations: [], + outcomes: [{ + key: 'restore-health', + label: 'Restore Health', + phase: 'after_event', + kind: 'restoration', + scope: 'self', + resource: 'health', + basis: 'flat', + delivery: 'continuous', + durationSeconds: 3, + variants: { + I: { value: 240 }, + II: { value: 336 } + } + }] + }] + }); + + const saved = await (await database.prepare( + 'SELECT delivery_mode,delivery_duration_seconds FROM conditional_effect_outcomes ORDER BY variant_key' + )).all(); + expect(saved).toEqual([ + { delivery_mode: 'continuous', delivery_duration_seconds: 3 }, + { delivery_mode: 'continuous', delivery_duration_seconds: 3 } + ]); + const loaded = await loadConditionalRuleDraft( + database, + 'talent', + 'predatory-aura', + ['I', 'II'] + ); + expect(loaded?.clauses[0].outcomes[0]).toMatchObject({ + delivery: 'continuous', + durationSeconds: 3, + variants: { + I: { value: 240 }, + II: { value: 336 } + } + }); }); }); diff --git a/src/lib/server/conditional-effects.ts b/src/lib/server/conditional-effects.ts index 43e7fd0..d6f737b 100644 --- a/src/lib/server/conditional-effects.ts +++ b/src/lib/server/conditional-effects.ts @@ -72,19 +72,24 @@ const outcomeMetadataColumns = [ 'application_phase', 'combat_timing', 'resource_kind', + 'delivery_mode', + 'delivery_duration_seconds', 'state_pool_id', 'min_state_value', 'max_state_value', 'description_slot' ] as const; -const comparableMetadataValue = (value: unknown) => value === undefined ? null : value; +const comparableMetadataValue = (column: typeof outcomeMetadataColumns[number], value: unknown) => { + if (column === 'delivery_mode') return value === undefined || value === null ? 'instant' : value; + return value === undefined ? null : value; +}; function assertVariantMetadataConsistent(effectKey: string, variants: OutcomeRow[]) { const first = variants[0]; for (const variant of variants.slice(1)) { const mismatch = outcomeMetadataColumns.find((column) => - comparableMetadataValue(variant[column]) !== comparableMetadataValue(first[column]) + comparableMetadataValue(column, variant[column]) !== comparableMetadataValue(column, first[column]) ); if (mismatch) { throw new Error( @@ -156,6 +161,22 @@ export function conditionalRuleDraftFromRows( assertVariantMetadataConsistent(key, variants); const first = variants[0]; const resource = outcomeResource(first); + const delivery = first.delivery_mode === undefined || first.delivery_mode === null + ? 'instant' + : String(first.delivery_mode); + const durationSeconds = first.delivery_duration_seconds === undefined + || first.delivery_duration_seconds === null + ? null + : Number(first.delivery_duration_seconds); + if (delivery !== 'instant' && delivery !== 'continuous') { + throw new Error(`Conditional outcome "${key}" has an unsupported delivery mode.`); + } + if (delivery === 'continuous' && (!Number.isFinite(durationSeconds) || Number(durationSeconds) <= 0)) { + throw new Error(`Conditional outcome "${key}" continuous delivery requires a positive duration.`); + } + if (delivery === 'instant' && durationSeconds !== null) { + throw new Error(`Conditional outcome "${key}" instant delivery cannot set a duration.`); + } return { key, label: String(first.outcome_key || key), @@ -165,6 +186,9 @@ export function conditionalRuleDraftFromRows( basis: String(first.value_basis) as ConditionalMechanicOutcomeDraft['basis'], ...(resource ? { resource } : {}), ...(first.combat_timing ? { combatTiming: String(first.combat_timing) as 'pre_event' | 'current' } : {}), + ...(delivery === 'continuous' + ? { delivery, durationSeconds: durationSeconds as number } + : {}), ...(first.stat_attribute_id ? { attributeId: String(first.stat_attribute_id) } : {}), ...(first.state_pool_id && poolKeyById.has(String(first.state_pool_id)) ? { statePool: poolKeyById.get(String(first.state_pool_id)) } @@ -331,9 +355,10 @@ export async function saveConditionalRuleInTransaction( (id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key, application_kind,effect_scope,stat_attribute_id,value,value_expression_json, value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing, - resource_kind,min_state_value,max_state_value,description_slot,sort_order, + resource_kind,delivery_mode,delivery_duration_seconds, + min_state_value,max_state_value,description_slot,sort_order, created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run( + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run( randomUUID(), ruleId, clauseId, @@ -355,6 +380,8 @@ export async function saveConditionalRuleInTransaction( outcome.phase, outcome.combatTiming ?? 'current', outcome.resource ?? null, + outcome.delivery ?? 'instant', + outcome.durationSeconds ?? null, outcome.minStacks ?? null, outcome.maxStacks ?? null, outcome.descriptionSlot ?? null, diff --git a/src/routes/isac/mechanic-reference/+page.svelte b/src/routes/isac/mechanic-reference/+page.svelte index 6d4e696..b60b2e8 100644 --- a/src/routes/isac/mechanic-reference/+page.svelte +++ b/src/routes/isac/mechanic-reference/+page.svelte @@ -65,6 +65,7 @@ { syntax: 'SCENARIO.', use: 'A builder assumption supplied by Maximum Potential or Custom Scenario mode.' }, { syntax: 'COMBAT.', use: 'The current calculated combat state, including current or maximum Health.' }, { syntax: 'PRE-EVENT COMBAT.', use: 'Combat state captured before the current branch changes it.' }, + { syntax: 'ATTRIBUTE[]', use: 'The authoritative loadout total for a stable Attribute ID. Percentage Attributes use their displayed percentage value.' }, { syntax: 'EQUIPMENT.', use: 'A value from the equipped item, including OS Protocol talent upgrades.' }, { syntax: 'REDUCER VALUE', use: 'The number produced by the branch reducer.' }, { syntax: ' FOR AGENT / EVENT TARGET', use: 'The current value in an Agent- or target-scoped state pool.' } @@ -340,6 +341,14 @@
{#each bases as basis}{basis}{/each}
+ +
diff --git a/src/routes/isac/talents/+page.svelte b/src/routes/isac/talents/+page.svelte index b7dbe57..66bc3e3 100644 --- a/src/routes/isac/talents/+page.svelte +++ b/src/routes/isac/talents/+page.svelte @@ -34,7 +34,7 @@ scope: 'equipped_weapon', attributeId: '', basis: 'flat', - perStack: true, + perStack: false, descriptionSlot: 1, variants: { I: { value: 0 }, II: { value: 0 } } }]