Support live attribute restoration mechanics

This commit is contained in:
JB
2026-07-27 21:32:04 -04:00
parent 36dd018a4a
commit 568e46cd05
19 changed files with 979 additions and 31 deletions
+6
View File
@@ -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')), 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')), 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, min_state_value REAL,
max_state_value REAL, max_state_value REAL,
description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1), 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(min_state_value IS NULL OR min_state_value>=0),
CHECK(max_state_value IS NULL OR max_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(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( CHECK(
(application_kind='active_modifier' AND stat_attribute_id IS NOT NULL AND damage_basis IS NULL) (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) OR (application_kind='direct_damage' AND damage_basis IS NOT NULL)
+9 -2
View File
@@ -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, best-case value, and optional expiry. Outcome rows expose kind, target scope,
Attribute or resource when applicable, value basis, state scaling, phase, and Attribute or resource when applicable, value basis, state scaling, phase, and
every source-specific value column. The Value Model control supports static every source-specific value column. The Value Model control supports static
values, event fields, reducer output, source-defined scenario controls, and OS values, live Attribute percentages, event fields, reducer output, source-defined
Protocol upgrade scaling where the source permits it. 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 Action Family recipes are optional authoring starters. Present the recipe
catalog as a searchable assignment with only the parameters required by the catalog as a searchable assignment with only the parameters required by the
+18 -2
View File
@@ -425,8 +425,10 @@ The program graph uses three child tables:
band, and phase: `on_event`, `after_event`, `on_enter`, `on_exit`, or band, and phase: `on_event`, `after_event`, `on_enter`, `on_exit`, or
`while_active`. One typed value or expression exists for every variant `while_active`. One typed value or expression exists for every variant
required by the owning source. The common value models are static values, required by the owning source. The common value models are static values,
event-field values, reducer output, source-defined scenario values, and OS live Attribute percentages, event-field values, reducer output,
Protocol upgrade scaling. 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 The generic runtime in `src/lib/calculations/mechanic.ts` processes combat events
in timestamp order. Predicates decide whether an event enters a clause. Reducers 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, state mutations, enforces pool bounds and expiry, emits outcomes in phase order,
and records telemetry for each activation. 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 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 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 uses `requires_assumptions`, identifies the missing input, and stays out of the
+19
View File
@@ -1227,6 +1227,25 @@ if (sourceOwnedClauseColumns.some((column) => column.name === 'trigger_id')) {
ON conditional_effect_outcomes(rule_id,variant_key,outcome_key,sort_order)`); 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(` const legacyTalentOutcomes = await (await database.prepare(`
SELECT outcome.id,outcome.rule_id,outcome.variant_key,outcome.stat_attribute_id, SELECT outcome.id,outcome.rule_id,outcome.variant_key,outcome.stat_attribute_id,
outcome.effect_key,outcome.description_slot,outcome.sort_order outcome.effect_key,outcome.description_slot,outcome.sort_order
+7 -3
View File
@@ -455,7 +455,8 @@ function evaluateConfiguredMechanic(
weaponBaseDamage: number, weaponBaseDamage: number,
maxHealth: number, maxHealth: number,
calculationMode: LoadoutDraft['calculationMode'], calculationMode: LoadoutDraft['calculationMode'],
equipmentContext = { protocolTalentUpgrades: 0 } equipmentContext = { protocolTalentUpgrades: 0 },
attributes: Record<string, number> = {}
): ActiveMechanicEvaluation | null { ): ActiveMechanicEvaluation | null {
if (calculationMode !== 'maximum' && state.mode === 'off') return null; if (calculationMode !== 'maximum' && state.mode === 'off') return null;
const encounterSeconds = Math.max(1, Number(profile.encounterSeconds)); const encounterSeconds = Math.max(1, Number(profile.encounterSeconds));
@@ -470,6 +471,7 @@ function evaluateConfiguredMechanic(
maxHealth: Math.max(1, maxHealth), maxHealth: Math.max(1, maxHealth),
currentHealth: Math.max(0, maxHealth * Number(profile.healthPercent) / 100), currentHealth: Math.max(0, maxHealth * Number(profile.healthPercent) / 100),
weaponBaseDamage, weaponBaseDamage,
attributes,
values: { extraHealth: profile.extraHealthActive ? 1 : 0 } values: { extraHealth: profile.extraHealthActive ? 1 : 0 }
}, },
equipmentContext equipmentContext
@@ -818,7 +820,8 @@ function skillModifierWindows(
Number(primarySelection.damage ?? 0), Number(primarySelection.damage ?? 0),
maxHealth, maxHealth,
draft.calculationMode, draft.calculationMode,
source.equipmentContext source.equipmentContext,
baseAttributes
); );
return evaluation ? [{ ...source, evaluation }] : []; return evaluation ? [{ ...source, evaluation }] : [];
}); });
@@ -920,7 +923,8 @@ export function evaluateLoadoutCombat(
Number(selection.damage), Number(selection.damage),
maxHealth, maxHealth,
draft.calculationMode, draft.calculationMode,
source.equipmentContext source.equipmentContext,
criticalAttributes
); );
return evaluation ? [{ ...source, state, evaluation }] : []; return evaluation ? [{ ...source, state, evaluation }] : [];
}); });
@@ -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', () => { it('requires the exact configured variants and returns validation errors without throwing', () => {
const invalid = draft(); const invalid = draft();
invalid.clauses[0].outcomes[0].variants = { invalid.clauses[0].outcomes[0].variants = {
+43
View File
@@ -156,6 +156,8 @@ function isReference(value: unknown): value is ValueReference {
&& (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current'); && (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current');
case 'equipment': case 'equipment':
return typeof value.path === 'string' && Boolean(value.path.trim()); return typeof value.path === 'string' && Boolean(value.path.trim());
case 'attribute':
return typeof value.attributeId === 'string' && Boolean(value.attributeId.trim());
case 'reducer': case 'reducer':
return true; return true;
case 'pool': case 'pool':
@@ -167,6 +169,33 @@ function isReference(value: unknown): value is ValueReference {
} }
} }
function validateAttributeReferences(
value: unknown,
allowedAttributeIds: ReadonlySet<string> | 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 { function isExpression(value: unknown): value is NumericExpression {
if (finite(value)) return true; if (finite(value)) return true;
if (!object(value)) return false; if (!object(value)) return false;
@@ -357,6 +386,19 @@ function parseOutcome(
} else if (value.resource !== undefined) { } else if (value.resource !== undefined) {
errors.push(`${path} may only use a resource for restoration or resource cost.`); 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 ( if (
value.combatTiming !== undefined value.combatTiming !== undefined
&& value.combatTiming !== 'pre_event' && value.combatTiming !== 'pre_event'
@@ -547,6 +589,7 @@ export function parseConditionalMechanicDraft(
pools, pools,
clauses clauses
}; };
validateAttributeReferences(draft, options.allowedAttributeIds, errors);
const inputRequiresAssumption = scenarioInputs.some( const inputRequiresAssumption = scenarioInputs.some(
(input) => input.readiness === 'requires_assumption' (input) => input.readiness === 'requires_assumption'
); );
@@ -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', () => { it('adapts the Happy Survival branches, explicit cadence, health timing, and upgrade references', () => {
@@ -82,6 +82,8 @@ export type ConditionalEffectOutcomeRow = {
value_basis: DatabaseValue; value_basis: DatabaseValue;
resource_kind?: DatabaseValue; resource_kind?: DatabaseValue;
combat_timing?: DatabaseValue; combat_timing?: DatabaseValue;
delivery_mode?: DatabaseValue;
delivery_duration_seconds?: DatabaseValue;
value_scaling: DatabaseValue; value_scaling: DatabaseValue;
unit: DatabaseValue; unit: DatabaseValue;
damage_basis: DatabaseValue; damage_basis: DatabaseValue;
@@ -530,6 +532,17 @@ function parseOutcome(
if (combatTiming && combatTiming !== 'pre_event' && combatTiming !== 'current') { if (combatTiming && combatTiming !== 'pre_event' && combatTiming !== 'current') {
errors.push(`${path} has invalid combat timing.`); 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 { return {
clauseId: nullableText(row.clause_id), clauseId: nullableText(row.clause_id),
outcome: { outcome: {
@@ -546,6 +559,9 @@ function parseOutcome(
...(combatTiming === 'pre_event' || combatTiming === 'current' ...(combatTiming === 'pre_event' || combatTiming === 'current'
? { combatTiming } ? { combatTiming }
: {}), : {}),
...(delivery === 'continuous' && durationSeconds !== null
? { delivery, durationSeconds }
: {}),
...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id) ...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id)
? { attributeId: text(row.stat_attribute_id) } ? { attributeId: text(row.stat_attribute_id) }
: {}), : {}),
+165
View File
@@ -784,6 +784,140 @@ describe('structured mechanic runtime', () => {
expect(result.script).toContain('COUNT 3 CONSECUTIVE MATCHES PER EVENT.targetId'); expect(result.script).toContain('COUNT 3 CONSECUTIVE MATCHES PER EVENT.targetId');
expect(result.script).toContain('[RECIPE distinct-enemies-within-window]'); 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', () => { 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', () => { it('formats nested conditions and state operations deterministically', () => {
const program: MechanicProgram = { const program: MechanicProgram = {
id: 'format', id: 'format',
+188 -11
View File
@@ -164,6 +164,7 @@ export type ValueReference =
| { source: 'event'; path: string } | { source: 'event'; path: string }
| { source: 'scenario'; key: string } | { source: 'scenario'; key: string }
| { source: 'combat'; path: string; timing?: 'pre_event' | 'current' } | { source: 'combat'; path: string; timing?: 'pre_event' | 'current' }
| { source: 'attribute'; attributeId: string }
| { source: 'equipment'; path: string } | { source: 'equipment'; path: string }
| { source: 'reducer' } | { source: 'reducer' }
| { source: 'pool'; pool: string; entity?: EntitySelector }; | { source: 'pool'; pool: string; entity?: EntitySelector };
@@ -263,6 +264,8 @@ export type MechanicOutcome = {
value: NumericExpression; value: NumericExpression;
basis: OutcomeBasis; basis: OutcomeBasis;
resource?: MechanicResource; resource?: MechanicResource;
delivery?: 'instant' | 'continuous';
durationSeconds?: number;
combatTiming?: 'pre_event' | 'current'; combatTiming?: 'pre_event' | 'current';
attributeId?: string; attributeId?: string;
statePool?: string; statePool?: string;
@@ -337,6 +340,10 @@ export type MechanicOutcomeEmission = {
stateValue: number; stateValue: number;
combatBefore: MechanicCombatContext | null; combatBefore: MechanicCombatContext | null;
combatAfter: MechanicCombatContext | null; combatAfter: MechanicCombatContext | null;
delivery: 'instant' | 'continuous';
deliveryStart: number | null;
deliveryEnd: number | null;
totalResolvedValue: number;
}; };
export type MechanicEvaluation = { export type MechanicEvaluation = {
@@ -366,6 +373,7 @@ export type MechanicCombatContext = {
maxAmmo?: number; maxAmmo?: number;
currentAmmo?: number; currentAmmo?: number;
weaponBaseDamage?: number; weaponBaseDamage?: number;
attributes?: Record<string, number>;
values?: Record<string, Scalar>; values?: Record<string, Scalar>;
}; };
@@ -389,6 +397,24 @@ type EvaluationContext = {
preEventCombat: MechanicCombatContext | null; preEventCombat: MechanicCombatContext | null;
equipment: MechanicEquipmentContext | 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 = { type ReducerState = {
count: number; count: number;
timestamps: number[]; timestamps: number[];
@@ -459,6 +485,8 @@ function resolveReference(reference: ValueReference, context: EvaluationContext)
const combat = reference.timing === 'pre_event' ? context.preEventCombat : context.combat; const combat = reference.timing === 'pre_event' ? context.preEventCombat : context.combat;
return getPath(combat, reference.path); return getPath(combat, reference.path);
} }
case 'attribute':
return context.combat?.attributes?.[reference.attributeId];
case 'equipment': case 'equipment':
return getPath(context.equipment, reference.path); return getPath(context.equipment, reference.path);
case 'reducer': case 'reducer':
@@ -805,6 +833,7 @@ function phaseMatches(
function copyCombat(combat: MechanicCombatContext | null): MechanicCombatContext | null { function copyCombat(combat: MechanicCombatContext | null): MechanicCombatContext | null {
return combat ? { return combat ? {
...combat, ...combat,
attributes: combat.attributes ? { ...combat.attributes } : undefined,
values: combat.values ? { ...combat.values } : undefined values: combat.values ? { ...combat.values } : undefined
} : null; } : null;
} }
@@ -862,7 +891,8 @@ function emitOutcomes(
at: number, at: number,
weight: number, weight: number,
before: Map<string, number>, before: Map<string, number>,
context: EvaluationContext context: EvaluationContext,
continuousDeliveries?: ContinuousDelivery[]
): MechanicOutcomeEmission[] { ): MechanicOutcomeEmission[] {
const emissions: MechanicOutcomeEmission[] = []; const emissions: MechanicOutcomeEmission[] = [];
const eligible = (clause.outcomes ?? []).filter((outcome) => { const eligible = (clause.outcomes ?? []).filter((outcome) => {
@@ -890,6 +920,27 @@ function emitOutcomes(
? outcomeResource(outcome) ? outcomeResource(outcome)
: null; : null;
const targetResource = context.combat && resource ? combatResource(context.combat, resource) : 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) { if (outcome.kind === 'resource_cost' && targetResource) {
appliedValue = Math.min(targetResource.current, Math.max(0, appliedValue)); appliedValue = Math.min(targetResource.current, Math.max(0, appliedValue));
targetResource.setCurrent(targetResource.current - appliedValue); targetResource.setCurrent(targetResource.current - appliedValue);
@@ -919,12 +970,71 @@ function emitOutcomes(
weight, weight,
stateValue: current, stateValue: current,
combatBefore, combatBefore,
combatAfter: copyCombat(context.combat) combatAfter: copyCombat(context.combat),
delivery: 'instant',
deliveryStart: null,
deliveryEnd: null,
totalResolvedValue: resolvedValue * weight
}); });
} }
return emissions; 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( function expirePools(
at: number, at: number,
clauses: MechanicClause[], clauses: MechanicClause[],
@@ -932,7 +1042,8 @@ function expirePools(
pools: Map<string, RuntimePool>, pools: Map<string, RuntimePool>,
definitions: Map<string, PoolDefinition>, definitions: Map<string, PoolDefinition>,
combat: MechanicCombatContext | null, combat: MechanicCombatContext | null,
equipment: MechanicEquipmentContext | null equipment: MechanicEquipmentContext | null,
continuousDeliveries: ContinuousDelivery[]
): MechanicOutcomeEmission[] { ): MechanicOutcomeEmission[] {
const expired = [...pools.entries()] const expired = [...pools.entries()]
.filter(([, state]) => state.expiresAt !== null && state.expiresAt <= at + EPSILON) .filter(([, state]) => state.expiresAt !== null && state.expiresAt <= at + EPSILON)
@@ -943,6 +1054,7 @@ function expirePools(
let index = 0; let index = 0;
while (index < expired.length) { while (index < expired.length) {
const expiryAt = expired[index][1].expiresAt as number; const expiryAt = expired[index][1].expiresAt as number;
emissions.push(...advanceContinuousDeliveries(expiryAt, continuousDeliveries, combat));
const group: typeof expired = []; const group: typeof expired = [];
while ( while (
index < expired.length index < expired.length
@@ -987,7 +1099,8 @@ function expirePools(
expiryAt, expiryAt,
1, 1,
before, before,
context context,
continuousDeliveries
)); ));
} }
} }
@@ -1048,6 +1161,7 @@ function validateReference(reference: ValueReference, pools: Set<string>, inputs
} }
if (reference.source === 'event' && !reference.path.trim()) errors.push(`${path} requires an event path.`); 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 === '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.`); 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 required = false;
let weaponBaseDamage = false; let weaponBaseDamage = false;
let equipment = false; let equipment = false;
const attributeIds = new Set<string>();
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) { for (const clause of program.clauses) {
if (clause.if && predicateUsesCombat(clause.if)) required = true; if (clause.if && predicateUsesCombat(clause.if)) required = true;
if (clause.if && predicateUsesEquipment(clause.if)) equipment = 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; 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<MechanicProgram> { export function validateMechanicProgram(program: MechanicProgram): ValidationResult<MechanicProgram> {
@@ -1353,6 +1482,19 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
if (outcome.resource !== undefined && outcome.kind !== 'restoration' && outcome.kind !== 'resource_cost') { 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.`); 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<MechanicOutcome['kind'], readonly OutcomeBasis[]> = { const allowedBasis: Record<MechanicOutcome['kind'], readonly OutcomeBasis[]> = {
attribute_modifier: ['flat'], attribute_modifier: ['flat'],
direct_damage: ['flat', 'max_health_percent', 'current_health_percent', 'weapon_base_percent'], 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) { if (combatRequirements.weaponBaseDamage && combatContext.weaponBaseDamage === undefined) {
scenarioErrors.push('This mechanic requires authoritative weapon base damage.'); 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) { if (combatRequirements.equipment && !equipmentContext) {
scenarioErrors.push('This mechanic requires authoritative equipment context.'); scenarioErrors.push('This mechanic requires authoritative equipment context.');
@@ -1472,6 +1619,7 @@ export function evaluateMechanicProgram(
const cooldowns = new Map<string, number>(); const cooldowns = new Map<string, number>();
const emissions: MechanicEmission[] = []; const emissions: MechanicEmission[] = [];
const outcomes: MechanicOutcomeEmission[] = []; const outcomes: MechanicOutcomeEmission[] = [];
const continuousDeliveries: ContinuousDelivery[] = [];
if (scenario.mode === 'maximum') { if (scenario.mode === 'maximum') {
for (const clause of program.clauses) { for (const clause of program.clauses) {
const event: MechanicEvent = { const event: MechanicEvent = {
@@ -1497,7 +1645,7 @@ export function evaluateMechanicProgram(
outcomes.push(...emitOutcomes({ outcomes.push(...emitOutcomes({
...clause, ...clause,
outcomes: (clause.outcomes ?? []).filter((outcome) => outcome.phase === 'while_active') outcomes: (clause.outcomes ?? []).filter((outcome) => outcome.phase === 'while_active')
}, 'after', 0, 1, before, context)); }, 'after', 0, 1, before, context, continuousDeliveries));
} }
} }
const ordered = events const ordered = events
@@ -1514,8 +1662,10 @@ export function evaluateMechanicProgram(
pools, pools,
poolDefinitions, poolDefinitions,
combat, combat,
equipment equipment,
continuousDeliveries
)); ));
outcomes.push(...advanceContinuousDeliveries(event.at, continuousDeliveries, combat));
const preEventCombat = copyCombat(combat); const preEventCombat = copyCombat(combat);
for (const clause of program.clauses) { for (const clause of program.clauses) {
if (clause.when !== event.kind) continue; if (clause.when !== event.kind) continue;
@@ -1544,7 +1694,15 @@ export function evaluateMechanicProgram(
.map((outcome) => outcome.statePool) .map((outcome) => outcome.statePool)
.filter((pool): pool is string => Boolean(pool))); .filter((pool): pool is string => Boolean(pool)));
const before = new Map([...relevantPools].map((pool) => [pool, stateValue(pool, undefined, context)])); 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) => const mutationRecords = clause.mutations.map((mutation) =>
applyMutation(mutation, reduction.at, context, weight)); applyMutation(mutation, reduction.at, context, weight));
emissions.push({ emissions.push({
@@ -1557,7 +1715,15 @@ export function evaluateMechanicProgram(
if (clause.cooldownSeconds) { if (clause.cooldownSeconds) {
cooldowns.set(stateKey, reduction.at + 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, pools,
poolDefinitions, poolDefinitions,
combat, combat,
equipment equipment,
continuousDeliveries
));
outcomes.push(...advanceContinuousDeliveries(
scenario.encounterSeconds,
continuousDeliveries,
combat
)); ));
return { return {
status: 'OK', status: 'OK',
@@ -1613,6 +1785,8 @@ function formatReference(reference: ValueReference): string {
return `SCENARIO.${reference.key}`; return `SCENARIO.${reference.key}`;
case 'combat': case 'combat':
return `${reference.timing === 'pre_event' ? 'PRE-EVENT ' : ''}COMBAT.${reference.path}`; return `${reference.timing === 'pre_event' ? 'PRE-EVENT ' : ''}COMBAT.${reference.path}`;
case 'attribute':
return `ATTRIBUTE[${reference.attributeId}]`;
case 'equipment': case 'equipment':
return `EQUIPMENT.${reference.path}`; return `EQUIPMENT.${reference.path}`;
case 'reducer': case 'reducer':
@@ -1717,7 +1891,10 @@ function formatOutcome(outcome: MechanicOutcome): string {
const timing = outcome.basis === 'current_health_percent' const timing = outcome.basis === 'current_health_percent'
? ` USING ${(outcome.combatTiming ?? 'current') === 'pre_event' ? 'PRE-EVENT' : 'CURRENT'} HEALTH` ? ` 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 { export function formatMechanicProgram(program: MechanicProgram): string {
@@ -12,7 +12,8 @@
subjectLabel = 'TALENTS', subjectLabel = 'TALENTS',
searchLabel = `FILTER AVAILABLE ${subjectLabel}`, searchLabel = `FILTER AVAILABLE ${subjectLabel}`,
searchPlaceholder = `Search ${subjectLabel.toLocaleLowerCase()}…`, searchPlaceholder = `Search ${subjectLabel.toLocaleLowerCase()}…`,
emptySelectionLabel = `NO ${subjectLabel} ASSIGNED` emptySelectionLabel = `NO ${subjectLabel} ASSIGNED`,
onvaluechange
}: { }: {
items: Item[]; items: Item[];
selected?: string[]; selected?: string[];
@@ -26,6 +27,7 @@
searchLabel?: string; searchLabel?: string;
searchPlaceholder?: string; searchPlaceholder?: string;
emptySelectionLabel?: string; emptySelectionLabel?: string;
onvaluechange?: (value: string) => void;
} = $props(); } = $props();
let query = $state(''); let query = $state('');
let open = $state(false); let open = $state(false);
@@ -42,6 +44,7 @@
const toggle = (id: string) => { const toggle = (id: string) => {
if (mode === 'single') { if (mode === 'single') {
value = value === id ? '' : id; value = value === id ? '' : id;
onvaluechange?.(value);
if (compact) { if (compact) {
open = false; open = false;
query = ''; query = '';
@@ -60,6 +60,7 @@
let recipeWindow = $state(3); let recipeWindow = $state(3);
let recipeDistance = $state(10); let recipeDistance = $state(10);
let replaceConfirmation = $state(false); let replaceConfirmation = $state(false);
let valueModelOverrides = $state<Record<string, OutcomeValueModel>>({});
const readinessOptions = [ const readinessOptions = [
{ value: 'ready', label: 'READY' }, { value: 'ready', label: 'READY' },
@@ -173,7 +174,7 @@
kind: 'attribute_modifier', kind: 'attribute_modifier',
scope: 'self', scope: 'self',
basis: 'flat', basis: 'flat',
perStack: true, perStack: false,
descriptionSlot: index + 1, descriptionSlot: index + 1,
attributeId: '', attributeId: '',
variants: Object.fromEntries(valueColumns.map((column) => [ variants: Object.fromEntries(valueColumns.map((column) => [
@@ -233,6 +234,7 @@
type OutcomeValueModel = type OutcomeValueModel =
| 'static' | 'static'
| 'event' | 'event'
| 'attribute'
| 'reducer' | 'reducer'
| 'scenario' | 'scenario'
| 'protocol_upgrades' | 'protocol_upgrades'
@@ -241,6 +243,7 @@
const valueModelOptions = $derived([ const valueModelOptions = $derived([
{ value: 'static', label: 'STATIC' }, { value: 'static', label: 'STATIC' },
{ value: 'event', label: 'EVENT FIELD' }, { value: 'event', label: 'EVENT FIELD' },
{ value: 'attribute', label: 'ATTRIBUTE ÷ 100' },
{ value: 'reducer', label: 'REDUCER' }, { value: 'reducer', label: 'REDUCER' },
{ value: 'scenario', label: 'SCENARIO' }, { value: 'scenario', label: 'SCENARIO' },
...(allowProtocolUpgradeScaling ...(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 { function outcomeValueModel(outcome: ConditionalMechanicOutcomeDraft): OutcomeValueModel {
if (valueModelOverrides[outcome.key]) return valueModelOverrides[outcome.key];
const expression = Object.values(outcome.variants)[0]?.expression; const expression = Object.values(outcome.variants)[0]?.expression;
if (expression === undefined) return 'static'; if (expression === undefined) return 'static';
if ( if (
@@ -298,6 +314,14 @@
if (expression.right.ref.source === 'scenario') return 'scenario'; if (expression.right.ref.source === 'scenario') return 'scenario';
if (expression.right.ref.source === 'reducer') return 'reducer'; if (expression.right.ref.source === 'reducer') return 'reducer';
} }
if (
'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 ( if (
'op' in expression.right 'op' in expression.right
&& expression.right.op === 'add' && expression.right.op === 'add'
@@ -330,12 +354,32 @@
return source === 'event' ? 'values.enemyCount' : rule.scenarioInputs[0]?.key ?? ''; 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( function expressionForModel(
model: OutcomeValueModel, model: OutcomeValueModel,
value: number, value: number,
reference = '' reference = ''
): NumericExpression | undefined { ): NumericExpression | undefined {
if (model === 'event') return referenceExpression('event', reference || 'values.enemyCount', value); if (model === 'event') return referenceExpression('event', reference || 'values.enemyCount', value);
if (model === 'attribute') return reference
? attributePercentExpression(value, reference)
: undefined;
if (model === 'scenario') return reference if (model === 'scenario') return reference
? referenceExpression('scenario', reference, value) ? referenceExpression('scenario', reference, value)
: undefined; : undefined;
@@ -353,6 +397,10 @@
reference = '' reference = ''
) { ) {
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex]; 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 variants = Object.fromEntries(Object.entries(outcome.variants).map(([key, variant]) => {
const expression = expressionForModel(model, Number(variant.value), reference); const expression = expressionForModel(model, Number(variant.value), reference);
return [key, expression === undefined ? { value: Number(variant.value) } : { return [key, expression === undefined ? { value: Number(variant.value) } : {
@@ -383,6 +431,17 @@
expression as { right: { ref: { key: string } } } expression as { right: { ref: { key: string } } }
).right.ref.key, value); ).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') { if (model === 'reducer') {
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } }; return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
} }
@@ -633,7 +692,7 @@
<div class="outcome-card"> <div class="outcome-card">
<div class="outcome-grid"> <div class="outcome-grid">
<label class="field">LABEL<input value={outcome.label} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label> <label class="field">LABEL<input value={outcome.label} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label>
<SegmentedChoice legend="KIND" name={`${idPrefix}-kind-${clauseIndex}-${outcomeIndex}`} options={outcomeKinds} value={outcome.kind} onchange={(values) => 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})}/> <SegmentedChoice legend="KIND" name={`${idPrefix}-kind-${clauseIndex}-${outcomeIndex}`} options={outcomeKinds} value={outcome.kind} onchange={(values) => 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})}/>
<label class="field">DESCRIPTION SLOT<input type="number" min="1" step="1" value={outcome.descriptionSlot ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{descriptionSlot:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label> <label class="field">DESCRIPTION SLOT<input type="number" min="1" step="1" value={outcome.descriptionSlot ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{descriptionSlot:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
</div> </div>
<SegmentedChoice legend="TARGET SCOPE" name={`${idPrefix}-scope-${clauseIndex}-${outcomeIndex}`} options={scopeOptions} value={outcome.scope} columns={scopeOptions.length > 4 ? 4 : scopeOptions.length} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{scope:values[0] as MechanicOutcomeScope})}/> <SegmentedChoice legend="TARGET SCOPE" name={`${idPrefix}-scope-${clauseIndex}-${outcomeIndex}`} options={scopeOptions} value={outcome.scope} columns={scopeOptions.length > 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'} {:else if outcome.kind === 'restoration' || outcome.kind === 'resource_cost'}
<SegmentedChoice legend="RESOURCE" name={`${idPrefix}-resource-${clauseIndex}-${outcomeIndex}`} options={[{value:'health',label:'HEALTH'},{value:'armor',label:'ARMOR'},{value:'ammo',label:'AMMO'}]} value={outcome.resource ?? 'health'} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{resource:values[0] as 'health'|'armor'|'ammo'})}/> <SegmentedChoice legend="RESOURCE" name={`${idPrefix}-resource-${clauseIndex}-${outcomeIndex}`} options={[{value:'health',label:'HEALTH'},{value:'armor',label:'ARMOR'},{value:'ammo',label:'AMMO'}]} value={outcome.resource ?? 'health'} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{resource:values[0] as 'health'|'armor'|'ammo'})}/>
{/if} {/if}
{#if outcome.kind === 'restoration'}
<div class="delivery-controls">
<SegmentedChoice
legend="DELIVERY"
name={`${idPrefix}-delivery-${clauseIndex}-${outcomeIndex}`}
options={[{value:'instant',label:'INSTANT'},{value:'continuous',label:'CONTINUOUS'}]}
value={outcome.delivery ?? 'instant'}
onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{
delivery: values[0] === 'continuous' ? 'continuous' : undefined,
durationSeconds: values[0] === 'continuous' ? outcome.durationSeconds ?? 3 : undefined
})}
/>
{#if outcome.delivery === 'continuous'}
<label class="field">DURATION (SECONDS)<input type="number" min="0.01" step="any" value={outcome.durationSeconds ?? 3} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{durationSeconds:Number(event.currentTarget.value)})}/></label>
{/if}
</div>
{/if}
{#if outcome.kind === 'direct_damage' || outcome.kind === 'restoration' || outcome.kind === 'resource_cost'} {#if outcome.kind === 'direct_damage' || outcome.kind === 'restoration' || outcome.kind === 'resource_cost'}
<SegmentedChoice legend="VALUE BASIS" name={`${idPrefix}-basis-${clauseIndex}-${outcomeIndex}`} options={[{value:'flat',label:'FLAT'},{value:'max_health_percent',label:'MAX HEALTH %'},{value:'current_health_percent',label:'CURRENT HEALTH %'},{value:'weapon_base_percent',label:'WEAPON BASE %'}]} value={outcome.basis} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{basis:values[0] as OutcomeBasis})}/> <SegmentedChoice legend="VALUE BASIS" name={`${idPrefix}-basis-${clauseIndex}-${outcomeIndex}`} options={[{value:'flat',label:'FLAT'},{value:'max_health_percent',label:'MAX HEALTH %'},{value:'current_health_percent',label:'CURRENT HEALTH %'},{value:'weapon_base_percent',label:'WEAPON BASE %'}]} value={outcome.basis} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{basis:values[0] as OutcomeBasis})}/>
{/if} {/if}
@@ -671,6 +747,15 @@
columns={Math.min(4,rule.scenarioInputs.length)} columns={Math.min(4,rule.scenarioInputs.length)}
onchange={(values) => setOutcomeValueModel(clauseIndex,outcomeIndex,'scenario',values[0])} onchange={(values) => setOutcomeValueModel(clauseIndex,outcomeIndex,'scenario',values[0])}
/> />
{:else if outcomeValueModel(outcome) === 'attribute'}
<SearchableAssignment
items={attributes}
mode="single"
compact
subjectLabel="VALUE ATTRIBUTE"
value={outcomeAttributeReference(outcome)}
onvaluechange={(value) => setOutcomeValueModel(clauseIndex,outcomeIndex,'attribute',value)}
/>
{/if} {/if}
{#if rule.pools.length} {#if rule.pools.length}
<div class="state-scaling"> <div class="state-scaling">
@@ -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} .outcome-grid{display:grid;grid-template-columns:minmax(200px,1fr) minmax(360px,1.4fr) minmax(120px,.3fr);align-items:end;gap:8px}
.variant-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px} .variant-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px}
.state-scaling{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr) repeat(2,minmax(110px,.3fr));align-items:end;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} .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,.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)} .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)} .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} .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: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: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,.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: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}} @media(prefers-reduced-motion:reduce){.remove,.danger-quiet{transition:none}}
</style> </style>
@@ -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');
});
});
@@ -1,7 +1,7 @@
import type { AiMechanicAuthoringRequest } from '$lib/ai/mechanic-authoring'; import type { AiMechanicAuthoringRequest } from '$lib/ai/mechanic-authoring';
import type { AiMechanicAttribute } from './mechanic-authoring-context'; import type { AiMechanicAttribute } from './mechanic-authoring-context';
export const AI_MECHANIC_PROMPT_VERSION = 2; export const AI_MECHANIC_PROMPT_VERSION = 3;
const mechanicsGrammar = { const mechanicsGrammar = {
version: 1, version: 1,
@@ -39,13 +39,14 @@ const mechanicsGrammar = {
], ],
mutationKinds: ['none', 'add', 'set', 'consume', 'derive', 'clear', 'transfer'], mutationKinds: ['none', 'add', 'set', 'consume', 'derive', 'clear', 'transfer'],
entitySelectors: ['agent', 'event_actor', 'event_target', 'event_field'], 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'], numericExpressionOperators: ['add', 'subtract', 'multiply', 'divide', 'min', 'max', 'clamp'],
referenceShapes: [ referenceShapes: [
{ ref: { source: 'event', path: 'values.distanceMeters' } }, { ref: { source: 'event', path: 'values.distanceMeters' } },
{ ref: { source: 'scenario', key: 'input-key' } }, { ref: { source: 'scenario', key: 'input-key' } },
{ ref: { source: 'combat', path: 'currentHealth', timing: 'current' } }, { ref: { source: 'combat', path: 'currentHealth', timing: 'current' } },
{ ref: { source: 'equipment', path: 'protocolTalentUpgrades' } }, { ref: { source: 'equipment', path: 'protocolTalentUpgrades' } },
{ ref: { source: 'attribute', attributeId: 'replace-with-an-exact-catalog-attribute-id' } },
{ ref: { source: 'reducer' } }, { ref: { source: 'reducer' } },
{ ref: { source: 'pool', pool: 'pool-key', entity: { kind: 'agent' } } } { ref: { source: 'pool', pool: 'pool-key', entity: { kind: 'agent' } } }
], ],
@@ -230,6 +231,8 @@ export function buildAiMechanicAuthoringInput(input: {
}, },
examplesAndRules: [ examplesAndRules: [
'Percent values use the numeric percentage shown in game, such as 3 for +3%, rather than 0.03.', '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:"<exact supplied catalog ID>"}. 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.', 'OS Protocol upgrade scaling may use equipment.protocolTalentUpgrades only when allowProtocolUpgradeScaling is true.',
'Assigned-ability scope may be used only when allowAssignedAbility is true.', 'Assigned-ability scope may be used only when allowAssignedAbility is true.',
'If cadence is not stated, ask rather than assuming a one-second interval.', 'If cadence is not stated, ask rather than assuming a one-second interval.',
+158 -1
View File
@@ -1,5 +1,10 @@
import { connect } from '@tursodatabase/database';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { conditionalRuleDraftFromRows } from './conditional-effects.js'; import {
conditionalRuleDraftFromRows,
loadConditionalRuleDraft,
saveConditionalRuleInTransaction
} from './conditional-effects.js';
const rule = { const rule = {
id: 'rule-1', id: 'rule-1',
@@ -72,5 +77,157 @@ describe('conditional mechanic persistence adapter', () => {
I: { value: 3 }, I: { value: 3 },
II: { value: 5 } 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 }
}
});
}); });
}); });
+31 -4
View File
@@ -72,19 +72,24 @@ const outcomeMetadataColumns = [
'application_phase', 'application_phase',
'combat_timing', 'combat_timing',
'resource_kind', 'resource_kind',
'delivery_mode',
'delivery_duration_seconds',
'state_pool_id', 'state_pool_id',
'min_state_value', 'min_state_value',
'max_state_value', 'max_state_value',
'description_slot' 'description_slot'
] as const; ] 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[]) { function assertVariantMetadataConsistent(effectKey: string, variants: OutcomeRow[]) {
const first = variants[0]; const first = variants[0];
for (const variant of variants.slice(1)) { for (const variant of variants.slice(1)) {
const mismatch = outcomeMetadataColumns.find((column) => const mismatch = outcomeMetadataColumns.find((column) =>
comparableMetadataValue(variant[column]) !== comparableMetadataValue(first[column]) comparableMetadataValue(column, variant[column]) !== comparableMetadataValue(column, first[column])
); );
if (mismatch) { if (mismatch) {
throw new Error( throw new Error(
@@ -156,6 +161,22 @@ export function conditionalRuleDraftFromRows(
assertVariantMetadataConsistent(key, variants); assertVariantMetadataConsistent(key, variants);
const first = variants[0]; const first = variants[0];
const resource = outcomeResource(first); 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 { return {
key, key,
label: String(first.outcome_key || key), label: String(first.outcome_key || key),
@@ -165,6 +186,9 @@ export function conditionalRuleDraftFromRows(
basis: String(first.value_basis) as ConditionalMechanicOutcomeDraft['basis'], basis: String(first.value_basis) as ConditionalMechanicOutcomeDraft['basis'],
...(resource ? { resource } : {}), ...(resource ? { resource } : {}),
...(first.combat_timing ? { combatTiming: String(first.combat_timing) as 'pre_event' | 'current' } : {}), ...(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.stat_attribute_id ? { attributeId: String(first.stat_attribute_id) } : {}),
...(first.state_pool_id && poolKeyById.has(String(first.state_pool_id)) ...(first.state_pool_id && poolKeyById.has(String(first.state_pool_id))
? { statePool: poolKeyById.get(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, (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, application_kind,effect_scope,stat_attribute_id,value,value_expression_json,
value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing, 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) created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run( VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
randomUUID(), randomUUID(),
ruleId, ruleId,
clauseId, clauseId,
@@ -355,6 +380,8 @@ export async function saveConditionalRuleInTransaction(
outcome.phase, outcome.phase,
outcome.combatTiming ?? 'current', outcome.combatTiming ?? 'current',
outcome.resource ?? null, outcome.resource ?? null,
outcome.delivery ?? 'instant',
outcome.durationSeconds ?? null,
outcome.minStacks ?? null, outcome.minStacks ?? null,
outcome.maxStacks ?? null, outcome.maxStacks ?? null,
outcome.descriptionSlot ?? null, outcome.descriptionSlot ?? null,
@@ -65,6 +65,7 @@
{ syntax: 'SCENARIO.<key>', use: 'A builder assumption supplied by Maximum Potential or Custom Scenario mode.' }, { syntax: 'SCENARIO.<key>', use: 'A builder assumption supplied by Maximum Potential or Custom Scenario mode.' },
{ syntax: 'COMBAT.<path>', use: 'The current calculated combat state, including current or maximum Health.' }, { syntax: 'COMBAT.<path>', use: 'The current calculated combat state, including current or maximum Health.' },
{ syntax: 'PRE-EVENT COMBAT.<path>', use: 'Combat state captured before the current branch changes it.' }, { syntax: 'PRE-EVENT COMBAT.<path>', use: 'Combat state captured before the current branch changes it.' },
{ syntax: 'ATTRIBUTE[<id>]', use: 'The authoritative loadout total for a stable Attribute ID. Percentage Attributes use their displayed percentage value.' },
{ syntax: 'EQUIPMENT.<path>', use: 'A value from the equipped item, including OS Protocol talent upgrades.' }, { syntax: 'EQUIPMENT.<path>', use: 'A value from the equipped item, including OS Protocol talent upgrades.' },
{ syntax: 'REDUCER VALUE', use: 'The number produced by the branch reducer.' }, { syntax: 'REDUCER VALUE', use: 'The number produced by the branch reducer.' },
{ syntax: '<pool> FOR AGENT / EVENT TARGET', use: 'The current value in an Agent- or target-scoped state pool.' } { syntax: '<pool> FOR AGENT / EVENT TARGET', use: 'The current value in an Agent- or target-scoped state pool.' }
@@ -340,6 +341,14 @@
<div class="basis-list">{#each bases as basis}<code>{basis}</code>{/each}</div> <div class="basis-list">{#each bases as basis}<code>{basis}</code>{/each}</div>
</div> </div>
</div> </div>
<aside class="contract">
<strong>CONTINUOUS RESTORATION</strong>
<span><code>DELIVER TOTAL CONTINUOUSLY OVER xs</code> distributes one resolved restoration total at a constant rate. Overlapping activations add independently, and excess restoration is lost when it reaches the resource maximum.</span>
</aside>
<aside class="contract">
<strong>LIVE ATTRIBUTE VALUES</strong>
<span><code>ATTRIBUTE[&lt;id&gt;]</code> reads the current loadout value by stable Attribute ID. For a displayed percentage, divide by 100 before applying a flat coefficient: <code>240 * (ATTRIBUTE[&lt;id&gt;] / 100)</code>.</span>
</aside>
</section> </section>
<section id="scenarios" class="reference-section"> <section id="scenarios" class="reference-section">
+1 -1
View File
@@ -34,7 +34,7 @@
scope: 'equipped_weapon', scope: 'equipped_weapon',
attributeId: '', attributeId: '',
basis: 'flat', basis: 'flat',
perStack: true, perStack: false,
descriptionSlot: 1, descriptionSlot: 1,
variants: { I: { value: 0 }, II: { value: 0 } } variants: { I: { value: 0 }, II: { value: 0 } }
}] }]