Support live attribute restoration mechanics

This commit is contained in:
JB
2026-07-27 21:32:04 -04:00
parent 36dd018a4a
commit 568e46cd05
19 changed files with 979 additions and 31 deletions
+6
View File
@@ -675,6 +675,8 @@ CREATE TABLE IF NOT EXISTS conditional_effect_outcomes (
)),
combat_timing TEXT NOT NULL DEFAULT 'current' CHECK(combat_timing IN ('pre_event','current')),
resource_kind TEXT CHECK(resource_kind IS NULL OR resource_kind IN ('health','armor','ammo')),
delivery_mode TEXT NOT NULL DEFAULT 'instant' CHECK(delivery_mode IN ('instant','continuous')),
delivery_duration_seconds REAL CHECK(delivery_duration_seconds IS NULL OR delivery_duration_seconds>0),
min_state_value REAL,
max_state_value REAL,
description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1),
@@ -685,6 +687,10 @@ CREATE TABLE IF NOT EXISTS conditional_effect_outcomes (
CHECK(min_state_value IS NULL OR min_state_value>=0),
CHECK(max_state_value IS NULL OR max_state_value>=0),
CHECK(min_state_value IS NULL OR max_state_value IS NULL OR max_state_value>=min_state_value),
CHECK(
(delivery_mode='instant' AND delivery_duration_seconds IS NULL)
OR (delivery_mode='continuous' AND delivery_duration_seconds IS NOT NULL)
),
CHECK(
(application_kind='active_modifier' AND stat_attribute_id IS NOT NULL AND damage_basis IS NULL)
OR (application_kind='direct_damage' AND damage_basis IS NOT NULL)
+9 -2
View File
@@ -2009,8 +2009,15 @@ script. State rows expose label, agent or target scope, initial value, bounds,
best-case value, and optional expiry. Outcome rows expose kind, target scope,
Attribute or resource when applicable, value basis, state scaling, phase, and
every source-specific value column. The Value Model control supports static
values, event fields, reducer output, source-defined scenario controls, and OS
Protocol upgrade scaling where the source permits it.
values, live Attribute percentages, event fields, reducer output, source-defined
scenario controls, and OS Protocol upgrade scaling where the source permits it.
Selecting a live Attribute uses the shared searchable single-assignment pattern
and displays the Attribute's unit. Label the percentage-normalized model
`ATTRIBUTE ÷ 100` so authors can see the transform applied to the stored value.
Restoration outcomes also expose an
`INSTANT` or `CONTINUOUS` segmented choice. Continuous delivery reveals one
required duration field and describes the configured value as the total
delivered across that duration; switching back to instant clears the duration.
Action Family recipes are optional authoring starters. Present the recipe
catalog as a searchable assignment with only the parameters required by the
+18 -2
View File
@@ -425,8 +425,10 @@ The program graph uses three child tables:
band, and phase: `on_event`, `after_event`, `on_enter`, `on_exit`, or
`while_active`. One typed value or expression exists for every variant
required by the owning source. The common value models are static values,
event-field values, reducer output, source-defined scenario values, and OS
Protocol upgrade scaling.
live Attribute percentages, event-field values, reducer output,
source-defined scenario values, and OS Protocol upgrade scaling. Restoration
outcomes may deliver their total immediately or continuously over an explicit
duration.
The generic runtime in `src/lib/calculations/mechanic.ts` processes combat events
in timestamp order. Predicates decide whether an event enters a clause. Reducers
@@ -434,6 +436,20 @@ convert event streams into activations or derived values. The runtime applies
state mutations, enforces pool bounds and expiry, emits outcomes in phase order,
and records telemetry for each activation.
Live Attribute references use a stable Attribute ID and read the authoritative
loadout total that the builder captures for the evaluation pass. Percentage
Attributes retain their displayed units, so an expression that applies a
coefficient to a percentage divides the Attribute value by 100. Evaluation
fails when the loadout does not supply a referenced Attribute; the runtime does
not substitute zero.
Continuous restoration resolves the outcome's total when the branch activates,
then delivers that total at a constant rate over the configured duration.
Activations create independent streams, overlapping streams add, and restoration
above the resource maximum is lost when it occurs. The runtime integrates
delivery at event and expiry boundaries before processing the event at that
timestamp, which keeps results independent of an arbitrary tick interval.
Periodic behavior requires an interval in the reducer. The runtime does not
infer a one-second cadence from missing data. A mechanic with an unknown cadence
uses `requires_assumptions`, identifies the missing input, and stays out of the
+19
View File
@@ -1227,6 +1227,25 @@ if (sourceOwnedClauseColumns.some((column) => column.name === 'trigger_id')) {
ON conditional_effect_outcomes(rule_id,variant_key,outcome_key,sort_order)`);
}
// Legacy conditional-mechanic table rebuilds above predate delivery metadata.
// Recheck after those migrations so their replacement tables receive the
// additive columns too.
const postRebuildOutcomeColumns = await (
await database.prepare("PRAGMA table_info('conditional_effect_outcomes')")
).all();
if (!postRebuildOutcomeColumns.some((column) => column.name === 'delivery_mode')) {
console.log('Adding outcome delivery modes to conditional mechanics.');
await database.exec(`ALTER TABLE conditional_effect_outcomes
ADD COLUMN delivery_mode TEXT NOT NULL DEFAULT 'instant'
CHECK(delivery_mode IN ('instant','continuous'))`);
}
if (!postRebuildOutcomeColumns.some((column) => column.name === 'delivery_duration_seconds')) {
console.log('Adding continuous-delivery durations to conditional mechanics.');
await database.exec(`ALTER TABLE conditional_effect_outcomes
ADD COLUMN delivery_duration_seconds REAL
CHECK(delivery_duration_seconds IS NULL OR delivery_duration_seconds>0)`);
}
const legacyTalentOutcomes = await (await database.prepare(`
SELECT outcome.id,outcome.rule_id,outcome.variant_key,outcome.stat_attribute_id,
outcome.effect_key,outcome.description_slot,outcome.sort_order
+7 -3
View File
@@ -455,7 +455,8 @@ function evaluateConfiguredMechanic(
weaponBaseDamage: number,
maxHealth: number,
calculationMode: LoadoutDraft['calculationMode'],
equipmentContext = { protocolTalentUpgrades: 0 }
equipmentContext = { protocolTalentUpgrades: 0 },
attributes: Record<string, number> = {}
): ActiveMechanicEvaluation | null {
if (calculationMode !== 'maximum' && state.mode === 'off') return null;
const encounterSeconds = Math.max(1, Number(profile.encounterSeconds));
@@ -470,6 +471,7 @@ function evaluateConfiguredMechanic(
maxHealth: Math.max(1, maxHealth),
currentHealth: Math.max(0, maxHealth * Number(profile.healthPercent) / 100),
weaponBaseDamage,
attributes,
values: { extraHealth: profile.extraHealthActive ? 1 : 0 }
},
equipmentContext
@@ -818,7 +820,8 @@ function skillModifierWindows(
Number(primarySelection.damage ?? 0),
maxHealth,
draft.calculationMode,
source.equipmentContext
source.equipmentContext,
baseAttributes
);
return evaluation ? [{ ...source, evaluation }] : [];
});
@@ -920,7 +923,8 @@ export function evaluateLoadoutCombat(
Number(selection.damage),
maxHealth,
draft.calculationMode,
source.equipmentContext
source.equipmentContext,
criticalAttributes
);
return evaluation ? [{ ...source, state, evaluation }] : [];
});
@@ -179,6 +179,90 @@ describe('conditional mechanic authoring payload', () => {
]);
});
it('accepts catalog Attribute references and rejects unknown IDs recursively', () => {
const referenced = draft();
referenced.clauses[0].outcomes[0] = {
key: 'health-restoration',
label: 'Health Restoration',
phase: 'after_event',
kind: 'restoration',
resource: 'health',
scope: 'self',
basis: 'flat',
delivery: 'continuous',
durationSeconds: 3,
variants: {
I: {
value: 240,
expression: {
op: 'multiply',
left: 240,
right: {
op: 'divide',
left: {
ref: {
source: 'attribute',
attributeId: 'firepower-multiplier'
}
},
right: 100
}
}
},
II: {
value: 336,
expression: {
op: 'multiply',
left: 336,
right: {
op: 'divide',
left: {
ref: {
source: 'attribute',
attributeId: 'firepower-multiplier'
}
},
right: 100
}
}
}
}
};
const accepted = parseConditionalMechanicDraft(referenced, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['firepower-multiplier'])
});
expect(accepted.ok).toBe(true);
if (accepted.ok) {
expect(accepted.value.programs.I.clauses[0].outcomes?.[0].value).toMatchObject({
right: {
left: {
ref: {
source: 'attribute',
attributeId: 'firepower-multiplier'
}
}
}
});
expect(accepted.value.programs.II.clauses[0].outcomes?.[0]).toMatchObject({
delivery: 'continuous',
durationSeconds: 3,
value: { left: 336 }
});
}
const rejected = parseConditionalMechanicDraft(referenced, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage'])
});
expect(rejected.ok).toBe(false);
if (!rejected.ok) {
expect(rejected.errors).toContain(
'Conditional mechanic clauses item 1 outcomes item 1 variants I expression right left ref references unknown Attribute "firepower-multiplier".'
);
}
});
it('requires the exact configured variants and returns validation errors without throwing', () => {
const invalid = draft();
invalid.clauses[0].outcomes[0].variants = {
+43
View File
@@ -156,6 +156,8 @@ function isReference(value: unknown): value is ValueReference {
&& (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current');
case 'equipment':
return typeof value.path === 'string' && Boolean(value.path.trim());
case 'attribute':
return typeof value.attributeId === 'string' && Boolean(value.attributeId.trim());
case 'reducer':
return true;
case 'pool':
@@ -167,6 +169,33 @@ function isReference(value: unknown): value is ValueReference {
}
}
function validateAttributeReferences(
value: unknown,
allowedAttributeIds: ReadonlySet<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 {
if (finite(value)) return true;
if (!object(value)) return false;
@@ -357,6 +386,19 @@ function parseOutcome(
} else if (value.resource !== undefined) {
errors.push(`${path} may only use a resource for restoration or resource cost.`);
}
const delivery = value.delivery ?? 'instant';
if (delivery !== 'instant' && delivery !== 'continuous') {
errors.push(`${path} has an invalid delivery mode.`);
} else if (delivery === 'continuous') {
if (kind !== 'restoration') {
errors.push(`${path} may only use continuous delivery for restoration.`);
}
if (!finite(value.durationSeconds) || value.durationSeconds <= 0) {
errors.push(`${path} continuous delivery requires a positive duration.`);
}
} else if (value.durationSeconds !== undefined) {
errors.push(`${path} may only set a duration for continuous delivery.`);
}
if (
value.combatTiming !== undefined
&& value.combatTiming !== 'pre_event'
@@ -547,6 +589,7 @@ export function parseConditionalMechanicDraft(
pools,
clauses
};
validateAttributeReferences(draft, options.allowedAttributeIds, errors);
const inputRequiresAssumption = scenarioInputs.some(
(input) => input.readiness === 'requires_assumption'
);
@@ -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', () => {
@@ -82,6 +82,8 @@ export type ConditionalEffectOutcomeRow = {
value_basis: DatabaseValue;
resource_kind?: DatabaseValue;
combat_timing?: DatabaseValue;
delivery_mode?: DatabaseValue;
delivery_duration_seconds?: DatabaseValue;
value_scaling: DatabaseValue;
unit: DatabaseValue;
damage_basis: DatabaseValue;
@@ -530,6 +532,17 @@ function parseOutcome(
if (combatTiming && combatTiming !== 'pre_event' && combatTiming !== 'current') {
errors.push(`${path} has invalid combat timing.`);
}
const delivery = nullableText(row.delivery_mode) ?? 'instant';
if (delivery !== 'instant' && delivery !== 'continuous') {
errors.push(`${path} has an unsupported delivery mode.`);
}
const durationSeconds = number(row.delivery_duration_seconds);
if (delivery === 'continuous' && (durationSeconds === null || durationSeconds <= 0)) {
errors.push(`${path} continuous delivery requires a positive duration.`);
}
if (delivery === 'instant' && durationSeconds !== null) {
errors.push(`${path} instant delivery cannot set a duration.`);
}
return {
clauseId: nullableText(row.clause_id),
outcome: {
@@ -546,6 +559,9 @@ function parseOutcome(
...(combatTiming === 'pre_event' || combatTiming === 'current'
? { combatTiming }
: {}),
...(delivery === 'continuous' && durationSeconds !== null
? { delivery, durationSeconds }
: {}),
...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id)
? { attributeId: text(row.stat_attribute_id) }
: {}),
+165
View File
@@ -784,6 +784,140 @@ describe('structured mechanic runtime', () => {
expect(result.script).toContain('COUNT 3 CONSECUTIVE MATCHES PER EVENT.targetId');
expect(result.script).toContain('[RECIPE distinct-enemies-within-window]');
});
it('resolves live Attribute percentages and delivers a restoration total continuously', () => {
const firepowerMultiplierId = '82a95611-283b-4fd8-a6ed-3567a316f36e';
const program: MechanicProgram = {
id: 'predatory-aura',
name: 'Predatory Aura',
clauses: [{
id: 'restore-on-kill',
when: 'enemy_killed',
reducer: { kind: 'none' },
mutations: [],
outcomes: [{
id: 'health-restoration',
label: 'Health restoration',
phase: 'after_event',
kind: 'restoration',
scope: 'self',
resource: 'health',
basis: 'flat',
delivery: 'continuous',
durationSeconds: 3,
value: {
op: 'multiply',
left: 240,
right: {
op: 'divide',
left: { ref: { source: 'attribute', attributeId: firepowerMultiplierId } },
right: 100
}
}
}]
}]
};
const result = evaluateMechanicProgram(
program,
[{ kind: 'enemy_killed', at: 0 }],
custom(3),
{
maxHealth: 1_000,
currentHealth: 500,
attributes: { [firepowerMultiplierId]: 100 }
}
);
expect(result.status).toBe('OK');
expect(result.outcomes).toHaveLength(1);
expect(result.outcomes[0]).toMatchObject({
at: 3,
delivery: 'continuous',
deliveryStart: 0,
deliveryEnd: 3,
totalResolvedValue: 240,
resolvedValue: 240,
appliedValue: 240
});
expect(result.finalCombat?.currentHealth).toBe(740);
expect(result.script).toContain(
'ATTRIBUTE[82a95611-283b-4fd8-a6ed-3567a316f36e] / 100'
);
expect(result.script).toContain('DELIVER TOTAL CONTINUOUSLY OVER 3s');
const missingAttribute = evaluateMechanicProgram(
program,
[{ kind: 'enemy_killed', at: 0 }],
custom(3),
{ maxHealth: 1_000, currentHealth: 500 }
);
expect(missingAttribute.status).toBe('INVALID');
expect(missingAttribute.errors).toContain(
`This mechanic requires authoritative Attribute ${firepowerMultiplierId}.`
);
});
it('integrates overlapping restoration streams at event boundaries without banking overheal', () => {
const program: MechanicProgram = {
id: 'continuous-restoration',
name: 'Continuous Restoration',
clauses: [{
id: 'restore',
when: 'enemy_killed',
reducer: { kind: 'none' },
mutations: [],
outcomes: [{
id: 'restore-health',
label: 'Restore Health',
phase: 'after_event',
kind: 'restoration',
scope: 'self',
resource: 'health',
basis: 'flat',
value: 240,
delivery: 'continuous',
durationSeconds: 3
}]
}, {
id: 'damage-between-streams',
when: 'agent_hit',
reducer: { kind: 'none' },
mutations: [],
outcomes: [{
id: 'health-cost',
label: 'Incoming damage proxy',
phase: 'on_event',
kind: 'resource_cost',
scope: 'self',
resource: 'health',
basis: 'flat',
value: 100
}]
}]
};
const result = evaluateMechanicProgram(
program,
[
{ kind: 'enemy_killed', at: 0 },
{ kind: 'agent_hit', at: 1 },
{ kind: 'enemy_killed', at: 1 }
],
custom(4),
{ maxHealth: 1_000, currentHealth: 500 }
);
const continuous = result.outcomes.filter((outcome) => outcome.delivery === 'continuous');
expect(continuous[0]).toMatchObject({ at: 1, resolvedValue: 80, appliedValue: 80 });
expect(continuous.reduce((total, outcome) => total + outcome.resolvedValue, 0)).toBeCloseTo(480);
expect(continuous.reduce((total, outcome) => total + outcome.appliedValue, 0)).toBeCloseTo(480);
expect(result.finalCombat?.currentHealth).toBe(880);
const capped = evaluateMechanicProgram(
program,
[{ kind: 'enemy_killed', at: 0 }],
custom(3),
{ maxHealth: 1_000, currentHealth: 950 }
);
expect(capped.outcomes.reduce((total, outcome) => total + outcome.appliedValue, 0)).toBe(50);
expect(capped.finalCombat?.currentHealth).toBe(1_000);
});
});
describe('mechanic validation and pseudo-script', () => {
@@ -866,6 +1000,37 @@ describe('mechanic validation and pseudo-script', () => {
}
});
it('rejects invalid continuous-delivery combinations', () => {
const base = {
id: 'delivery-validation',
name: 'Delivery Validation',
clauses: [{
id: 'invalid-delivery',
when: 'enemy_killed' as const,
reducer: { kind: 'none' as const },
mutations: [],
outcomes: [{
id: 'damage-over-time',
label: 'Damage',
phase: 'after_event' as const,
kind: 'direct_damage' as const,
scope: 'target' as const,
basis: 'flat' as const,
value: 10,
delivery: 'continuous' as const,
durationSeconds: 3
}]
}]
};
const result = validateMechanicProgram(base);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors).toContain(
'Clause 1 outcome 1 can only use continuous delivery for restoration.'
);
}
});
it('formats nested conditions and state operations deterministically', () => {
const program: MechanicProgram = {
id: 'format',
+188 -11
View File
@@ -164,6 +164,7 @@ export type ValueReference =
| { source: 'event'; path: string }
| { source: 'scenario'; key: string }
| { source: 'combat'; path: string; timing?: 'pre_event' | 'current' }
| { source: 'attribute'; attributeId: string }
| { source: 'equipment'; path: string }
| { source: 'reducer' }
| { source: 'pool'; pool: string; entity?: EntitySelector };
@@ -263,6 +264,8 @@ export type MechanicOutcome = {
value: NumericExpression;
basis: OutcomeBasis;
resource?: MechanicResource;
delivery?: 'instant' | 'continuous';
durationSeconds?: number;
combatTiming?: 'pre_event' | 'current';
attributeId?: string;
statePool?: string;
@@ -337,6 +340,10 @@ export type MechanicOutcomeEmission = {
stateValue: number;
combatBefore: MechanicCombatContext | null;
combatAfter: MechanicCombatContext | null;
delivery: 'instant' | 'continuous';
deliveryStart: number | null;
deliveryEnd: number | null;
totalResolvedValue: number;
};
export type MechanicEvaluation = {
@@ -366,6 +373,7 @@ export type MechanicCombatContext = {
maxAmmo?: number;
currentAmmo?: number;
weaponBaseDamage?: number;
attributes?: Record<string, number>;
values?: Record<string, Scalar>;
};
@@ -389,6 +397,24 @@ type EvaluationContext = {
preEventCombat: MechanicCombatContext | null;
equipment: MechanicEquipmentContext | null;
};
type ContinuousDelivery = {
clauseId: string;
outcomeId: string;
phase: OutcomePhase;
scope: MechanicOutcomeScope;
resource: MechanicResource;
attributeId: string | null;
basis: OutcomeBasis;
combatTiming: 'pre_event' | 'current';
value: number;
totalResolvedValue: number;
weight: number;
stateValue: number;
start: number;
end: number;
appliedThrough: number;
};
type ReducerState = {
count: number;
timestamps: number[];
@@ -459,6 +485,8 @@ function resolveReference(reference: ValueReference, context: EvaluationContext)
const combat = reference.timing === 'pre_event' ? context.preEventCombat : context.combat;
return getPath(combat, reference.path);
}
case 'attribute':
return context.combat?.attributes?.[reference.attributeId];
case 'equipment':
return getPath(context.equipment, reference.path);
case 'reducer':
@@ -805,6 +833,7 @@ function phaseMatches(
function copyCombat(combat: MechanicCombatContext | null): MechanicCombatContext | null {
return combat ? {
...combat,
attributes: combat.attributes ? { ...combat.attributes } : undefined,
values: combat.values ? { ...combat.values } : undefined
} : null;
}
@@ -862,7 +891,8 @@ function emitOutcomes(
at: number,
weight: number,
before: Map<string, number>,
context: EvaluationContext
context: EvaluationContext,
continuousDeliveries?: ContinuousDelivery[]
): MechanicOutcomeEmission[] {
const emissions: MechanicOutcomeEmission[] = [];
const eligible = (clause.outcomes ?? []).filter((outcome) => {
@@ -890,6 +920,27 @@ function emitOutcomes(
? outcomeResource(outcome)
: null;
const targetResource = context.combat && resource ? combatResource(context.combat, resource) : null;
if (outcome.kind === 'restoration' && outcome.delivery === 'continuous') {
const durationSeconds = outcome.durationSeconds as number;
continuousDeliveries?.push({
clauseId: clause.id,
outcomeId: outcome.id,
phase: outcome.phase,
scope: outcome.scope,
resource: outcomeResource(outcome),
attributeId: outcome.attributeId ?? null,
basis: outcome.basis,
combatTiming: outcome.combatTiming ?? 'current',
value,
totalResolvedValue: Math.max(0, resolvedValue * weight),
weight,
stateValue: current,
start: at,
end: at + durationSeconds,
appliedThrough: at
});
continue;
}
if (outcome.kind === 'resource_cost' && targetResource) {
appliedValue = Math.min(targetResource.current, Math.max(0, appliedValue));
targetResource.setCurrent(targetResource.current - appliedValue);
@@ -919,12 +970,71 @@ function emitOutcomes(
weight,
stateValue: current,
combatBefore,
combatAfter: copyCombat(context.combat)
combatAfter: copyCombat(context.combat),
delivery: 'instant',
deliveryStart: null,
deliveryEnd: null,
totalResolvedValue: resolvedValue * weight
});
}
return emissions;
}
function advanceContinuousDeliveries(
at: number,
deliveries: ContinuousDelivery[],
combat: MechanicCombatContext | null
): MechanicOutcomeEmission[] {
const emissions: MechanicOutcomeEmission[] = [];
for (const delivery of deliveries) {
const through = Math.min(at, delivery.end);
const elapsed = through - delivery.appliedThrough;
if (elapsed <= EPSILON) continue;
const duration = delivery.end - delivery.start;
const resolvedValue = delivery.totalResolvedValue * elapsed / duration;
const combatBefore = copyCombat(combat);
const targetResource = combat ? combatResource(combat, delivery.resource) : null;
let appliedValue = 0;
if (targetResource) {
appliedValue = Math.min(
targetResource.maximum - targetResource.current,
Math.max(0, resolvedValue)
);
targetResource.setCurrent(targetResource.current + appliedValue);
}
delivery.appliedThrough = through;
emissions.push({
clauseId: delivery.clauseId,
outcomeId: delivery.outcomeId,
at: through,
phase: delivery.phase,
kind: 'restoration',
scope: delivery.scope,
resource: delivery.resource,
attributeId: delivery.attributeId,
basis: delivery.basis,
combatTiming: delivery.combatTiming,
value: delivery.value,
resolvedValue,
appliedValue,
weight: delivery.weight,
stateValue: delivery.stateValue,
combatBefore,
combatAfter: copyCombat(combat),
delivery: 'continuous',
deliveryStart: delivery.start,
deliveryEnd: delivery.end,
totalResolvedValue: delivery.totalResolvedValue
});
}
for (let index = deliveries.length - 1; index >= 0; index -= 1) {
if (deliveries[index].appliedThrough >= deliveries[index].end - EPSILON) {
deliveries.splice(index, 1);
}
}
return emissions;
}
function expirePools(
at: number,
clauses: MechanicClause[],
@@ -932,7 +1042,8 @@ function expirePools(
pools: Map<string, RuntimePool>,
definitions: Map<string, PoolDefinition>,
combat: MechanicCombatContext | null,
equipment: MechanicEquipmentContext | null
equipment: MechanicEquipmentContext | null,
continuousDeliveries: ContinuousDelivery[]
): MechanicOutcomeEmission[] {
const expired = [...pools.entries()]
.filter(([, state]) => state.expiresAt !== null && state.expiresAt <= at + EPSILON)
@@ -943,6 +1054,7 @@ function expirePools(
let index = 0;
while (index < expired.length) {
const expiryAt = expired[index][1].expiresAt as number;
emissions.push(...advanceContinuousDeliveries(expiryAt, continuousDeliveries, combat));
const group: typeof expired = [];
while (
index < expired.length
@@ -987,7 +1099,8 @@ function expirePools(
expiryAt,
1,
before,
context
context,
continuousDeliveries
));
}
}
@@ -1048,6 +1161,7 @@ function validateReference(reference: ValueReference, pools: Set<string>, inputs
}
if (reference.source === 'event' && !reference.path.trim()) errors.push(`${path} requires an event path.`);
if (reference.source === 'combat' && !reference.path.trim()) errors.push(`${path} requires a combat path.`);
if (reference.source === 'attribute' && !reference.attributeId.trim()) errors.push(`${path} requires an Attribute ID.`);
if (reference.source === 'equipment' && !reference.path.trim()) errors.push(`${path} requires an equipment path.`);
}
@@ -1158,6 +1272,20 @@ function programCombatRequirements(program: MechanicProgram) {
let required = false;
let weaponBaseDamage = false;
let equipment = false;
const attributeIds = new Set<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) {
if (clause.if && predicateUsesCombat(clause.if)) required = true;
if (clause.if && predicateUsesEquipment(clause.if)) equipment = true;
@@ -1177,7 +1305,8 @@ function programCombatRequirements(program: MechanicProgram) {
if (outcome.basis === 'weapon_base_percent') weaponBaseDamage = true;
}
}
return { required, weaponBaseDamage, equipment };
if (attributeIds.size) required = true;
return { required, weaponBaseDamage, equipment, attributeIds };
}
export function validateMechanicProgram(program: MechanicProgram): ValidationResult<MechanicProgram> {
@@ -1353,6 +1482,19 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
if (outcome.resource !== undefined && outcome.kind !== 'restoration' && outcome.kind !== 'resource_cost') {
errors.push(`${outcomePath} can only set a resource for restoration or resource-cost outcomes.`);
}
const delivery = outcome.delivery ?? 'instant';
if (!['instant', 'continuous'].includes(delivery)) {
errors.push(`${outcomePath} has an unsupported delivery mode.`);
} else if (delivery === 'continuous') {
if (outcome.kind !== 'restoration') {
errors.push(`${outcomePath} can only use continuous delivery for restoration.`);
}
if (!finiteNumber(outcome.durationSeconds) || (outcome.durationSeconds ?? 0) <= 0) {
errors.push(`${outcomePath} continuous delivery duration must be greater than 0 seconds.`);
}
} else if (outcome.durationSeconds !== undefined) {
errors.push(`${outcomePath} can only set a duration for continuous delivery.`);
}
const allowedBasis: Record<MechanicOutcome['kind'], readonly OutcomeBasis[]> = {
attribute_modifier: ['flat'],
direct_damage: ['flat', 'max_health_percent', 'current_health_percent', 'weapon_base_percent'],
@@ -1439,6 +1581,11 @@ export function evaluateMechanicProgram(
if (combatRequirements.weaponBaseDamage && combatContext.weaponBaseDamage === undefined) {
scenarioErrors.push('This mechanic requires authoritative weapon base damage.');
}
for (const attributeId of combatRequirements.attributeIds) {
if (!finiteNumber(combatContext.attributes?.[attributeId])) {
scenarioErrors.push(`This mechanic requires authoritative Attribute ${attributeId}.`);
}
}
}
if (combatRequirements.equipment && !equipmentContext) {
scenarioErrors.push('This mechanic requires authoritative equipment context.');
@@ -1472,6 +1619,7 @@ export function evaluateMechanicProgram(
const cooldowns = new Map<string, number>();
const emissions: MechanicEmission[] = [];
const outcomes: MechanicOutcomeEmission[] = [];
const continuousDeliveries: ContinuousDelivery[] = [];
if (scenario.mode === 'maximum') {
for (const clause of program.clauses) {
const event: MechanicEvent = {
@@ -1497,7 +1645,7 @@ export function evaluateMechanicProgram(
outcomes.push(...emitOutcomes({
...clause,
outcomes: (clause.outcomes ?? []).filter((outcome) => outcome.phase === 'while_active')
}, 'after', 0, 1, before, context));
}, 'after', 0, 1, before, context, continuousDeliveries));
}
}
const ordered = events
@@ -1514,8 +1662,10 @@ export function evaluateMechanicProgram(
pools,
poolDefinitions,
combat,
equipment
equipment,
continuousDeliveries
));
outcomes.push(...advanceContinuousDeliveries(event.at, continuousDeliveries, combat));
const preEventCombat = copyCombat(combat);
for (const clause of program.clauses) {
if (clause.when !== event.kind) continue;
@@ -1544,7 +1694,15 @@ export function evaluateMechanicProgram(
.map((outcome) => outcome.statePool)
.filter((pool): pool is string => Boolean(pool)));
const before = new Map([...relevantPools].map((pool) => [pool, stateValue(pool, undefined, context)]));
outcomes.push(...emitOutcomes(clause, 'before', reduction.at, weight, before, context));
outcomes.push(...emitOutcomes(
clause,
'before',
reduction.at,
weight,
before,
context,
continuousDeliveries
));
const mutationRecords = clause.mutations.map((mutation) =>
applyMutation(mutation, reduction.at, context, weight));
emissions.push({
@@ -1557,7 +1715,15 @@ export function evaluateMechanicProgram(
if (clause.cooldownSeconds) {
cooldowns.set(stateKey, reduction.at + clause.cooldownSeconds);
}
outcomes.push(...emitOutcomes(clause, 'after', reduction.at, weight, before, context));
outcomes.push(...emitOutcomes(
clause,
'after',
reduction.at,
weight,
before,
context,
continuousDeliveries
));
}
}
}
@@ -1568,7 +1734,13 @@ export function evaluateMechanicProgram(
pools,
poolDefinitions,
combat,
equipment
equipment,
continuousDeliveries
));
outcomes.push(...advanceContinuousDeliveries(
scenario.encounterSeconds,
continuousDeliveries,
combat
));
return {
status: 'OK',
@@ -1613,6 +1785,8 @@ function formatReference(reference: ValueReference): string {
return `SCENARIO.${reference.key}`;
case 'combat':
return `${reference.timing === 'pre_event' ? 'PRE-EVENT ' : ''}COMBAT.${reference.path}`;
case 'attribute':
return `ATTRIBUTE[${reference.attributeId}]`;
case 'equipment':
return `EQUIPMENT.${reference.path}`;
case 'reducer':
@@ -1717,7 +1891,10 @@ function formatOutcome(outcome: MechanicOutcome): string {
const timing = outcome.basis === 'current_health_percent'
? ` USING ${(outcome.combatTiming ?? 'current') === 'pre_event' ? 'PRE-EVENT' : 'CURRENT'} HEALTH`
: '';
return `${outcome.phase.toUpperCase()}: APPLY ${formatExpression(outcome.value)} ${outcome.basis.toUpperCase()}${resource}${attribute}${perStack}${stackBand}${timing} // ${outcome.label}`;
const delivery = outcome.delivery === 'continuous'
? `; DELIVER TOTAL CONTINUOUSLY OVER ${formatNumber(outcome.durationSeconds ?? 0)}s`
: '';
return `${outcome.phase.toUpperCase()}: APPLY ${formatExpression(outcome.value)} ${outcome.basis.toUpperCase()}${resource}${attribute}${perStack}${stackBand}${timing}${delivery} // ${outcome.label}`;
}
export function formatMechanicProgram(program: MechanicProgram): string {
@@ -12,7 +12,8 @@
subjectLabel = 'TALENTS',
searchLabel = `FILTER AVAILABLE ${subjectLabel}`,
searchPlaceholder = `Search ${subjectLabel.toLocaleLowerCase()}…`,
emptySelectionLabel = `NO ${subjectLabel} ASSIGNED`
emptySelectionLabel = `NO ${subjectLabel} ASSIGNED`,
onvaluechange
}: {
items: Item[];
selected?: string[];
@@ -26,6 +27,7 @@
searchLabel?: string;
searchPlaceholder?: string;
emptySelectionLabel?: string;
onvaluechange?: (value: string) => void;
} = $props();
let query = $state('');
let open = $state(false);
@@ -42,6 +44,7 @@
const toggle = (id: string) => {
if (mode === 'single') {
value = value === id ? '' : id;
onvaluechange?.(value);
if (compact) {
open = false;
query = '';
@@ -60,6 +60,7 @@
let recipeWindow = $state(3);
let recipeDistance = $state(10);
let replaceConfirmation = $state(false);
let valueModelOverrides = $state<Record<string, OutcomeValueModel>>({});
const readinessOptions = [
{ value: 'ready', label: 'READY' },
@@ -173,7 +174,7 @@
kind: 'attribute_modifier',
scope: 'self',
basis: 'flat',
perStack: true,
perStack: false,
descriptionSlot: index + 1,
attributeId: '',
variants: Object.fromEntries(valueColumns.map((column) => [
@@ -233,6 +234,7 @@
type OutcomeValueModel =
| 'static'
| 'event'
| 'attribute'
| 'reducer'
| 'scenario'
| 'protocol_upgrades'
@@ -241,6 +243,7 @@
const valueModelOptions = $derived([
{ value: 'static', label: 'STATIC' },
{ value: 'event', label: 'EVENT FIELD' },
{ value: 'attribute', label: 'ATTRIBUTE ÷ 100' },
{ value: 'reducer', label: 'REDUCER' },
{ value: 'scenario', label: 'SCENARIO' },
...(allowProtocolUpgradeScaling
@@ -283,7 +286,20 @@
};
}
function attributePercentExpression(value: number, attributeId: string): NumericExpression {
return {
op: 'multiply',
left: value,
right: {
op: 'divide',
left: { ref: { source: 'attribute', attributeId } },
right: 100
}
};
}
function outcomeValueModel(outcome: ConditionalMechanicOutcomeDraft): OutcomeValueModel {
if (valueModelOverrides[outcome.key]) return valueModelOverrides[outcome.key];
const expression = Object.values(outcome.variants)[0]?.expression;
if (expression === undefined) return 'static';
if (
@@ -298,6 +314,14 @@
if (expression.right.ref.source === 'scenario') return 'scenario';
if (expression.right.ref.source === 'reducer') return 'reducer';
}
if (
'op' in expression.right
&& expression.right.op === 'divide'
&& typeof expression.right.left === 'object'
&& 'ref' in expression.right.left
&& expression.right.left.ref.source === 'attribute'
&& expression.right.right === 100
) return 'attribute';
if (
'op' in expression.right
&& expression.right.op === 'add'
@@ -330,12 +354,32 @@
return source === 'event' ? 'values.enemyCount' : rule.scenarioInputs[0]?.key ?? '';
}
function outcomeAttributeReference(outcome: ConditionalMechanicOutcomeDraft) {
const expression = Object.values(outcome.variants)[0]?.expression;
if (
expression
&& typeof expression === 'object'
&& 'op' in expression
&& expression.op === 'multiply'
&& typeof expression.right === 'object'
&& 'op' in expression.right
&& expression.right.op === 'divide'
&& typeof expression.right.left === 'object'
&& 'ref' in expression.right.left
&& expression.right.left.ref.source === 'attribute'
) return expression.right.left.ref.attributeId;
return '';
}
function expressionForModel(
model: OutcomeValueModel,
value: number,
reference = ''
): NumericExpression | undefined {
if (model === 'event') return referenceExpression('event', reference || 'values.enemyCount', value);
if (model === 'attribute') return reference
? attributePercentExpression(value, reference)
: undefined;
if (model === 'scenario') return reference
? referenceExpression('scenario', reference, value)
: undefined;
@@ -353,6 +397,10 @@
reference = ''
) {
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
const nextOverrides = { ...valueModelOverrides };
if (model === 'attribute' && !reference) nextOverrides[outcome.key] = 'attribute';
else delete nextOverrides[outcome.key];
valueModelOverrides = nextOverrides;
const variants = Object.fromEntries(Object.entries(outcome.variants).map(([key, variant]) => {
const expression = expressionForModel(model, Number(variant.value), reference);
return [key, expression === undefined ? { value: Number(variant.value) } : {
@@ -383,6 +431,17 @@
expression as { right: { ref: { key: string } } }
).right.ref.key, value);
}
if (model === 'attribute') {
return attributePercentExpression(value, outcomeAttributeReference({
key: 'temporary',
label: 'Temporary',
phase: 'on_event',
kind: 'marker',
scope: 'self',
basis: 'flat',
variants: { base: { value, expression } }
}));
}
if (model === 'reducer') {
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
}
@@ -633,7 +692,7 @@
<div class="outcome-card">
<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>
<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>
</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})}/>
@@ -642,6 +701,23 @@
{: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'})}/>
{/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'}
<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}
@@ -671,6 +747,15 @@
columns={Math.min(4,rule.scenarioInputs.length)}
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 rule.pools.length}
<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}
.variant-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px}
.state-scaling{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr) repeat(2,minmax(110px,.3fr));align-items:end;gap:8px}
.delivery-controls{display:grid;grid-template-columns:minmax(260px,1fr) minmax(150px,.35fr);align-items:end;gap:8px}.delivery-controls>.field{margin:0}
.outcome-footer{display:flex;align-items:end;justify-content:space-between;gap:8px}
.remove,.danger-quiet{min-height:38px;padding:0 10px;border:1px solid var(--shd-danger);background:transparent;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}
.remove:hover,.remove:focus-visible,.danger-quiet:hover,.danger-quiet:focus-visible{background:var(--shd-danger);color:var(--shd-surface)}
.compact-action{min-height:36px}.add-branch{min-height:44px}.empty-state{padding:10px}.script-preview{display:grid;grid-template-columns:minmax(190px,.28fr) minmax(0,1fr);gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-border);border-top:2px solid var(--shd-success);background:var(--shd-surface)}
.script-preview>div{display:grid;align-content:start;gap:3px}.script-preview>div span{color:var(--shd-success);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
@media(max-width:1300px){.scenario-row{grid-template-columns:repeat(2,minmax(0,1fr))}.scenario-row>.description-field{grid-column:1/-1}}
@media(max-width:1000px){.recipe-grid,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.script-preview{grid-template-columns:1fr 1fr}.recipe-grid>:first-child,.script-preview>:last-child{grid-column:1/-1}.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:1/-1}}
@media(max-width:620px){.mechanic-heading,.recipe-panel>header,.scenario-panel>header,.state-panel>header,.branch>header,.outcome-heading,.replace-confirm,.outcome-footer{align-items:stretch;flex-direction:column}.recipe-grid,.scenario-row,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.script-preview{grid-template-columns:1fr}.recipe-grid>:first-child,.script-preview>:last-child,.scenario-row>.description-field,.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:auto}.branch>header>div{align-items:stretch;flex-direction:column}.branch>header .field{min-width:0}.remove,.danger-quiet,.compact-action,.add-branch{width:100%;min-height:44px}}
@media(max-width:1000px){.recipe-grid,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.delivery-controls,.script-preview{grid-template-columns:1fr 1fr}.recipe-grid>:first-child,.script-preview>:last-child{grid-column:1/-1}.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:1/-1}}
@media(max-width:620px){.mechanic-heading,.recipe-panel>header,.scenario-panel>header,.state-panel>header,.branch>header,.outcome-heading,.replace-confirm,.outcome-footer{align-items:stretch;flex-direction:column}.recipe-grid,.scenario-row,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.delivery-controls,.script-preview{grid-template-columns:1fr}.recipe-grid>:first-child,.script-preview>:last-child,.scenario-row>.description-field,.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:auto}.branch>header>div{align-items:stretch;flex-direction:column}.branch>header .field{min-width:0}.remove,.danger-quiet,.compact-action,.add-branch{width:100%;min-height:44px}}
@media(prefers-reduced-motion:reduce){.remove,.danger-quiet{transition:none}}
</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 { AiMechanicAttribute } from './mechanic-authoring-context';
export const AI_MECHANIC_PROMPT_VERSION = 2;
export const AI_MECHANIC_PROMPT_VERSION = 3;
const mechanicsGrammar = {
version: 1,
@@ -39,13 +39,14 @@ const mechanicsGrammar = {
],
mutationKinds: ['none', 'add', 'set', 'consume', 'derive', 'clear', 'transfer'],
entitySelectors: ['agent', 'event_actor', 'event_target', 'event_field'],
valueReferences: ['event', 'scenario', 'combat', 'equipment', 'reducer', 'pool'],
valueReferences: ['event', 'scenario', 'combat', 'equipment', 'attribute', 'reducer', 'pool'],
numericExpressionOperators: ['add', 'subtract', 'multiply', 'divide', 'min', 'max', 'clamp'],
referenceShapes: [
{ ref: { source: 'event', path: 'values.distanceMeters' } },
{ ref: { source: 'scenario', key: 'input-key' } },
{ ref: { source: 'combat', path: 'currentHealth', timing: 'current' } },
{ ref: { source: 'equipment', path: 'protocolTalentUpgrades' } },
{ ref: { source: 'attribute', attributeId: 'replace-with-an-exact-catalog-attribute-id' } },
{ ref: { source: 'reducer' } },
{ ref: { source: 'pool', pool: 'pool-key', entity: { kind: 'agent' } } }
],
@@ -230,6 +231,8 @@ export function buildAiMechanicAuthoringInput(input: {
},
examplesAndRules: [
'Percent values use the numeric percentage shown in game, such as 3 for +3%, rather than 0.03.',
'Live Attribute references must use {source:"attribute",attributeId:"<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.',
'Assigned-ability scope may be used only when allowAssignedAbility is true.',
'If cadence is not stated, ask rather than assuming a one-second interval.',
+158 -1
View File
@@ -1,5 +1,10 @@
import { connect } from '@tursodatabase/database';
import { describe, expect, it } from 'vitest';
import { conditionalRuleDraftFromRows } from './conditional-effects.js';
import {
conditionalRuleDraftFromRows,
loadConditionalRuleDraft,
saveConditionalRuleInTransaction
} from './conditional-effects.js';
const rule = {
id: 'rule-1',
@@ -72,5 +77,157 @@ describe('conditional mechanic persistence adapter', () => {
I: { value: 3 },
II: { value: 5 }
});
expect(draft?.clauses[0].outcomes[0]).not.toHaveProperty('delivery');
expect(draft?.clauses[0].outcomes[0]).not.toHaveProperty('durationSeconds');
});
it('loads continuous delivery metadata and normalizes legacy instant variants', () => {
const continuous = conditionalRuleDraftFromRows(
rule,
[],
[clause],
[
{
...outcome,
application_kind: 'restore_health',
stat_attribute_id: null,
resource_kind: 'health',
delivery_mode: 'continuous',
delivery_duration_seconds: 3,
variant_key: 'I'
},
{
...outcome,
application_kind: 'restore_health',
stat_attribute_id: null,
resource_kind: 'health',
delivery_mode: 'continuous',
delivery_duration_seconds: 3,
variant_key: 'II'
}
],
['I', 'II']
);
expect(continuous?.clauses[0].outcomes[0]).toMatchObject({
delivery: 'continuous',
durationSeconds: 3
});
expect(() => conditionalRuleDraftFromRows(
rule,
[],
[clause],
[
{ ...outcome, variant_key: 'I' },
{ ...outcome, variant_key: 'II', delivery_mode: 'instant' }
],
['I', 'II']
)).not.toThrow();
});
it('rejects divergent continuous-delivery durations across variants', () => {
expect(() => conditionalRuleDraftFromRows(
rule,
[],
[clause],
[
{
...outcome,
variant_key: 'I',
delivery_mode: 'continuous',
delivery_duration_seconds: 3
},
{
...outcome,
variant_key: 'II',
delivery_mode: 'continuous',
delivery_duration_seconds: 4
}
],
['I', 'II']
)).toThrow(/inconsistent delivery_duration_seconds metadata/);
});
it('round-trips continuous delivery through normalized rows', async () => {
const database = await connect(':memory:');
await database.exec(`CREATE TABLE conditional_effect_rules (
id TEXT PRIMARY KEY, source_type TEXT, source_id TEXT, configured INTEGER,
script_version INTEGER, simulation_readiness TEXT, scenario_inputs_json TEXT,
created_at TEXT, updated_at TEXT, UNIQUE(source_type,source_id)
)`);
await database.exec(`CREATE TABLE conditional_effect_state_pools (
id TEXT PRIMARY KEY, rule_id TEXT, state_key TEXT, label TEXT, state_kind TEXT,
state_scope TEXT, initial_value REAL, minimum_value REAL, maximum_value REAL,
best_case_value REAL, duration_seconds REAL, expiry_policy TEXT, refresh_policy TEXT,
parameters_json TEXT, sort_order INTEGER, created_at TEXT, updated_at TEXT
)`);
await database.exec(`CREATE TABLE conditional_effect_clauses (
id TEXT PRIMARY KEY, rule_id TEXT, clause_key TEXT, label TEXT, event_kind TEXT,
recipe_key TEXT, recipe_parameters_json TEXT, conditions_json TEXT, reducer_json TEXT,
weight_expression_json TEXT, emission_kind TEXT, state_pool_id TEXT, mutations_json TEXT,
cooldown_seconds REAL, output_branch TEXT, sort_order INTEGER, active INTEGER,
created_at TEXT, updated_at TEXT
)`);
await database.exec(`CREATE TABLE conditional_effect_outcomes (
id TEXT PRIMARY KEY, rule_id TEXT, clause_id TEXT, state_pool_id TEXT, effect_key TEXT,
variant_key TEXT, outcome_key TEXT, application_kind TEXT, effect_scope TEXT,
stat_attribute_id TEXT, value REAL, value_expression_json TEXT, value_basis TEXT,
value_scaling TEXT, unit TEXT, damage_basis TEXT, application_phase TEXT,
combat_timing TEXT, resource_kind TEXT, delivery_mode TEXT,
delivery_duration_seconds REAL, min_state_value REAL, max_state_value REAL,
description_slot INTEGER, sort_order INTEGER, created_at TEXT, updated_at TEXT
)`);
await saveConditionalRuleInTransaction(database, 'talent', 'predatory-aura', {
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [],
clauses: [{
key: 'weapon-kill',
label: 'Weapon kill',
event: 'enemy_killed',
if: { kind: 'true' },
reducer: { kind: 'none' },
mutations: [],
outcomes: [{
key: 'restore-health',
label: 'Restore Health',
phase: 'after_event',
kind: 'restoration',
scope: 'self',
resource: 'health',
basis: 'flat',
delivery: 'continuous',
durationSeconds: 3,
variants: {
I: { value: 240 },
II: { value: 336 }
}
}]
}]
});
const saved = await (await database.prepare(
'SELECT delivery_mode,delivery_duration_seconds FROM conditional_effect_outcomes ORDER BY variant_key'
)).all();
expect(saved).toEqual([
{ delivery_mode: 'continuous', delivery_duration_seconds: 3 },
{ delivery_mode: 'continuous', delivery_duration_seconds: 3 }
]);
const loaded = await loadConditionalRuleDraft(
database,
'talent',
'predatory-aura',
['I', 'II']
);
expect(loaded?.clauses[0].outcomes[0]).toMatchObject({
delivery: 'continuous',
durationSeconds: 3,
variants: {
I: { value: 240 },
II: { value: 336 }
}
});
});
});
+31 -4
View File
@@ -72,19 +72,24 @@ const outcomeMetadataColumns = [
'application_phase',
'combat_timing',
'resource_kind',
'delivery_mode',
'delivery_duration_seconds',
'state_pool_id',
'min_state_value',
'max_state_value',
'description_slot'
] as const;
const comparableMetadataValue = (value: unknown) => value === undefined ? null : value;
const comparableMetadataValue = (column: typeof outcomeMetadataColumns[number], value: unknown) => {
if (column === 'delivery_mode') return value === undefined || value === null ? 'instant' : value;
return value === undefined ? null : value;
};
function assertVariantMetadataConsistent(effectKey: string, variants: OutcomeRow[]) {
const first = variants[0];
for (const variant of variants.slice(1)) {
const mismatch = outcomeMetadataColumns.find((column) =>
comparableMetadataValue(variant[column]) !== comparableMetadataValue(first[column])
comparableMetadataValue(column, variant[column]) !== comparableMetadataValue(column, first[column])
);
if (mismatch) {
throw new Error(
@@ -156,6 +161,22 @@ export function conditionalRuleDraftFromRows(
assertVariantMetadataConsistent(key, variants);
const first = variants[0];
const resource = outcomeResource(first);
const delivery = first.delivery_mode === undefined || first.delivery_mode === null
? 'instant'
: String(first.delivery_mode);
const durationSeconds = first.delivery_duration_seconds === undefined
|| first.delivery_duration_seconds === null
? null
: Number(first.delivery_duration_seconds);
if (delivery !== 'instant' && delivery !== 'continuous') {
throw new Error(`Conditional outcome "${key}" has an unsupported delivery mode.`);
}
if (delivery === 'continuous' && (!Number.isFinite(durationSeconds) || Number(durationSeconds) <= 0)) {
throw new Error(`Conditional outcome "${key}" continuous delivery requires a positive duration.`);
}
if (delivery === 'instant' && durationSeconds !== null) {
throw new Error(`Conditional outcome "${key}" instant delivery cannot set a duration.`);
}
return {
key,
label: String(first.outcome_key || key),
@@ -165,6 +186,9 @@ export function conditionalRuleDraftFromRows(
basis: String(first.value_basis) as ConditionalMechanicOutcomeDraft['basis'],
...(resource ? { resource } : {}),
...(first.combat_timing ? { combatTiming: String(first.combat_timing) as 'pre_event' | 'current' } : {}),
...(delivery === 'continuous'
? { delivery, durationSeconds: durationSeconds as number }
: {}),
...(first.stat_attribute_id ? { attributeId: String(first.stat_attribute_id) } : {}),
...(first.state_pool_id && poolKeyById.has(String(first.state_pool_id))
? { statePool: poolKeyById.get(String(first.state_pool_id)) }
@@ -331,9 +355,10 @@ export async function saveConditionalRuleInTransaction(
(id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key,
application_kind,effect_scope,stat_attribute_id,value,value_expression_json,
value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing,
resource_kind,min_state_value,max_state_value,description_slot,sort_order,
resource_kind,delivery_mode,delivery_duration_seconds,
min_state_value,max_state_value,description_slot,sort_order,
created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
randomUUID(),
ruleId,
clauseId,
@@ -355,6 +380,8 @@ export async function saveConditionalRuleInTransaction(
outcome.phase,
outcome.combatTiming ?? 'current',
outcome.resource ?? null,
outcome.delivery ?? 'instant',
outcome.durationSeconds ?? null,
outcome.minStacks ?? null,
outcome.maxStacks ?? null,
outcome.descriptionSlot ?? null,
@@ -65,6 +65,7 @@
{ 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: '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: '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.' }
@@ -340,6 +341,14 @@
<div class="basis-list">{#each bases as basis}<code>{basis}</code>{/each}</div>
</div>
</div>
<aside class="contract">
<strong>CONTINUOUS RESTORATION</strong>
<span><code>DELIVER TOTAL CONTINUOUSLY OVER xs</code> distributes one resolved restoration total at a constant rate. Overlapping activations add independently, and excess restoration is lost when it reaches the resource maximum.</span>
</aside>
<aside class="contract">
<strong>LIVE ATTRIBUTE VALUES</strong>
<span><code>ATTRIBUTE[&lt;id&gt;]</code> reads the current loadout value by stable Attribute ID. For a displayed percentage, divide by 100 before applying a flat coefficient: <code>240 * (ATTRIBUTE[&lt;id&gt;] / 100)</code>.</span>
</aside>
</section>
<section id="scenarios" class="reference-section">
+1 -1
View File
@@ -34,7 +34,7 @@
scope: 'equipped_weapon',
attributeId: '',
basis: 'flat',
perStack: true,
perStack: false,
descriptionSlot: 1,
variants: { I: { value: 0 }, II: { value: 0 } }
}]