Support live attribute restoration mechanics
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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) }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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[<id>]</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[<id>] / 100)</code>.</span>
|
||||||
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="scenarios" class="reference-section">
|
<section id="scenarios" class="reference-section">
|
||||||
|
|||||||
@@ -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 } }
|
||||||
}]
|
}]
|
||||||
|
|||||||
Reference in New Issue
Block a user