Update mechanic authoring persistence
This commit is contained in:
@@ -176,6 +176,38 @@ describe('loadout mechanic timeline', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('emits the Agent-granted-buff ally count from its exact scenario input and defaults it to zero', () => {
|
||||
const profile = createDraft().combatProfile;
|
||||
profile.encounterSeconds = 1;
|
||||
const configured = buildWeaponMechanicTimeline(
|
||||
{ rpm: 60, magazine: 100, reload_speed: 0 },
|
||||
profile,
|
||||
0,
|
||||
{
|
||||
scenarioInputs: {
|
||||
'allies-within-30m-with-agent-granted-buff': 4,
|
||||
'allies-within-radius': 2,
|
||||
},
|
||||
},
|
||||
).filter((event) => event.kind === 'proximity_snapshot');
|
||||
const defaults = buildWeaponMechanicTimeline(
|
||||
{ rpm: 60, magazine: 100, reload_speed: 0 },
|
||||
profile,
|
||||
).filter((event) => event.kind === 'proximity_snapshot');
|
||||
|
||||
expect(
|
||||
configured.map(
|
||||
(event) => event.values?.alliesWithin30MetersWithAgentGrantedBuff,
|
||||
),
|
||||
).toEqual([4, 4]);
|
||||
expect(configured.map((event) => event.values?.allyCount)).toEqual([2, 2]);
|
||||
expect(
|
||||
defaults.map(
|
||||
(event) => event.values?.alliesWithin30MetersWithAgentGrantedBuff,
|
||||
),
|
||||
).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it('seeds Extra Health state before periodic stack evaluation', () => {
|
||||
const profile = createDraft().combatProfile;
|
||||
profile.encounterSeconds = 2;
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
} from '$lib/calculations/combat';
|
||||
import {
|
||||
evaluateMechanicProgram,
|
||||
PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_VALUE_KEY,
|
||||
PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_INPUT,
|
||||
type MechanicCombatContext,
|
||||
type MechanicEvaluation,
|
||||
type MechanicEvent,
|
||||
@@ -803,6 +805,11 @@ export function buildWeaponMechanicTimeline(
|
||||
allyCount: Number(
|
||||
options.scenarioInputs?.['allies-within-radius'] ?? 0,
|
||||
),
|
||||
[PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_VALUE_KEY]: Number(
|
||||
options.scenarioInputs?.[
|
||||
PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_INPUT
|
||||
] ?? 0,
|
||||
),
|
||||
},
|
||||
});
|
||||
events.push({
|
||||
|
||||
@@ -2,11 +2,13 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
currentHealthPercentBandPredicate,
|
||||
eventFieldComparisonPredicate,
|
||||
eventFieldNumericExpression,
|
||||
mechanicTargetingForMode,
|
||||
mechanicTargetingMode,
|
||||
parseConditionalMechanicDraft,
|
||||
readCurrentHealthPercentBand,
|
||||
readEventFieldComparison,
|
||||
readEventFieldNumericExpression,
|
||||
registeredEventFields,
|
||||
readStatePoolThreshold,
|
||||
statePoolThresholdPredicate,
|
||||
@@ -202,6 +204,108 @@ describe('conditional mechanic authoring payload', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips the registered proximity count as a derived scalar and resolves each variant display value', () => {
|
||||
const path = 'values.alliesWithin30MetersWithAgentGrantedBuff';
|
||||
expect(registeredEventFields('proximity_snapshot')).toContainEqual({
|
||||
path,
|
||||
label: 'Allies within 30 meters with an Agent-granted buff',
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
integer: true,
|
||||
});
|
||||
|
||||
const expression = eventFieldNumericExpression(path);
|
||||
expect(readEventFieldNumericExpression(expression)).toBe(path);
|
||||
expect(
|
||||
readEventFieldNumericExpression({
|
||||
op: 'multiply',
|
||||
left: expression,
|
||||
right: 2,
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
const value = draft();
|
||||
value.clauses[0].event = 'proximity_snapshot';
|
||||
value.clauses[0].recipeKey = undefined;
|
||||
value.clauses[0].reducer = {
|
||||
kind: 'derived_scalar',
|
||||
value: expression,
|
||||
};
|
||||
value.clauses[0].mutations = [
|
||||
{
|
||||
kind: 'derive',
|
||||
pool: 'effect',
|
||||
value: { ref: { source: 'reducer' } },
|
||||
},
|
||||
];
|
||||
const upgradeScale = {
|
||||
op: 'add' as const,
|
||||
left: 1,
|
||||
right: {
|
||||
op: 'divide' as const,
|
||||
left: {
|
||||
ref: {
|
||||
source: 'equipment' as const,
|
||||
path: 'protocolTalentUpgrades',
|
||||
},
|
||||
},
|
||||
right: 5,
|
||||
},
|
||||
};
|
||||
value.clauses[0].outcomes[0].variants = {
|
||||
I: {
|
||||
value: 7.25,
|
||||
expression: {
|
||||
op: 'multiply',
|
||||
left: { ref: { source: 'variant_value' } },
|
||||
right: upgradeScale,
|
||||
},
|
||||
},
|
||||
II: {
|
||||
value: 9.5,
|
||||
expression: {
|
||||
op: 'multiply',
|
||||
left: { ref: { source: 'variant_value' } },
|
||||
right: upgradeScale,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = parseConditionalMechanicDraft(value, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage']),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.draft.clauses[0]).toMatchObject({
|
||||
event: 'proximity_snapshot',
|
||||
reducer: { kind: 'derived_scalar', value: expression },
|
||||
outcomes: [
|
||||
{
|
||||
variants: {
|
||||
I: {
|
||||
value: 7.25,
|
||||
expression: {
|
||||
left: { ref: { source: 'variant_value' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.value.programs.I.clauses[0].outcomes?.[0].value).toEqual({
|
||||
op: 'multiply',
|
||||
left: 7.25,
|
||||
right: upgradeScale,
|
||||
});
|
||||
expect(result.value.programs.II.clauses[0].outcomes?.[0].value).toEqual({
|
||||
op: 'multiply',
|
||||
left: 9.5,
|
||||
right: upgradeScale,
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes assigned-ability activity for kill and assist event conditions', () => {
|
||||
expect(registeredEventFields('enemy_killed')).toEqual(
|
||||
expect.arrayContaining([
|
||||
|
||||
@@ -39,9 +39,28 @@ export type ConditionalMechanicPoolDraft = PoolDefinition & {
|
||||
expiresAfterSecondsVariants?: Record<string, number>;
|
||||
};
|
||||
|
||||
export type ConditionalMechanicValueReference =
|
||||
ValueReference | { source: 'variant_value' };
|
||||
|
||||
export type ConditionalNumericExpression =
|
||||
| number
|
||||
| { ref: ConditionalMechanicValueReference }
|
||||
| { op: 'floor'; value: ConditionalNumericExpression }
|
||||
| {
|
||||
op: 'add' | 'subtract' | 'multiply' | 'divide' | 'min' | 'max';
|
||||
left: ConditionalNumericExpression;
|
||||
right: ConditionalNumericExpression;
|
||||
}
|
||||
| {
|
||||
op: 'clamp';
|
||||
value: ConditionalNumericExpression;
|
||||
min: ConditionalNumericExpression;
|
||||
max: ConditionalNumericExpression;
|
||||
};
|
||||
|
||||
export type ConditionalMechanicVariantValue = {
|
||||
value: number;
|
||||
expression?: NumericExpression;
|
||||
expression?: ConditionalNumericExpression;
|
||||
};
|
||||
|
||||
export type ConditionalMechanicOutcomeDraft = Omit<
|
||||
@@ -200,6 +219,21 @@ export function registeredEventFields(event: MechanicEventKind) {
|
||||
return MECHANIC_EVENT_REGISTRY[event].fields ?? [];
|
||||
}
|
||||
|
||||
export function eventFieldNumericExpression(path: string): NumericExpression {
|
||||
return { ref: { source: 'event', path } };
|
||||
}
|
||||
|
||||
export function readEventFieldNumericExpression(
|
||||
expression: NumericExpression,
|
||||
): string | null {
|
||||
return typeof expression === 'object' &&
|
||||
'ref' in expression &&
|
||||
expression.ref.source === 'event' &&
|
||||
expression.ref.path.trim()
|
||||
? expression.ref.path
|
||||
: null;
|
||||
}
|
||||
|
||||
export type MechanicTargetingMode =
|
||||
| 'none'
|
||||
| 'allies_within_radius'
|
||||
@@ -452,6 +486,14 @@ function isReference(value: unknown): value is ValueReference {
|
||||
}
|
||||
}
|
||||
|
||||
function isConditionalReference(
|
||||
value: unknown,
|
||||
): value is ConditionalMechanicValueReference {
|
||||
return (
|
||||
(object(value) && value.source === 'variant_value') || isReference(value)
|
||||
);
|
||||
}
|
||||
|
||||
function validateAttributeReferences(
|
||||
value: unknown,
|
||||
allowedAttributeIds: ReadonlySet<string> | undefined,
|
||||
@@ -511,6 +553,29 @@ function isExpression(value: unknown): value is NumericExpression {
|
||||
);
|
||||
}
|
||||
|
||||
function isConditionalExpression(
|
||||
value: unknown,
|
||||
): value is ConditionalNumericExpression {
|
||||
if (finite(value)) return true;
|
||||
if (!object(value)) return false;
|
||||
if ('ref' in value) return isConditionalReference(value.ref);
|
||||
if (value.op === 'floor') return isConditionalExpression(value.value);
|
||||
if (value.op === 'clamp') {
|
||||
return (
|
||||
isConditionalExpression(value.value) &&
|
||||
isConditionalExpression(value.min) &&
|
||||
isConditionalExpression(value.max)
|
||||
);
|
||||
}
|
||||
return (
|
||||
['add', 'subtract', 'multiply', 'divide', 'min', 'max'].includes(
|
||||
String(value.op),
|
||||
) &&
|
||||
isConditionalExpression(value.left) &&
|
||||
isConditionalExpression(value.right)
|
||||
);
|
||||
}
|
||||
|
||||
function isOperand(value: unknown) {
|
||||
return scalar(value) || isReference(value);
|
||||
}
|
||||
@@ -838,7 +903,7 @@ function parseOutcome(
|
||||
}
|
||||
if (
|
||||
configured.expression !== undefined &&
|
||||
!isExpression(configured.expression)
|
||||
!isConditionalExpression(configured.expression)
|
||||
) {
|
||||
errors.push(
|
||||
`${path} variant "${variant}" has an invalid value expression.`,
|
||||
@@ -954,6 +1019,37 @@ function parseClauses(
|
||||
return clauses;
|
||||
}
|
||||
|
||||
function resolveVariantExpression(
|
||||
expression: ConditionalNumericExpression,
|
||||
variantValue: number,
|
||||
): NumericExpression {
|
||||
if (typeof expression === 'number') return expression;
|
||||
if ('ref' in expression) {
|
||||
return expression.ref.source === 'variant_value'
|
||||
? variantValue
|
||||
: { ref: expression.ref };
|
||||
}
|
||||
if (expression.op === 'floor') {
|
||||
return {
|
||||
op: 'floor',
|
||||
value: resolveVariantExpression(expression.value, variantValue),
|
||||
};
|
||||
}
|
||||
if (expression.op === 'clamp') {
|
||||
return {
|
||||
op: 'clamp',
|
||||
value: resolveVariantExpression(expression.value, variantValue),
|
||||
min: resolveVariantExpression(expression.min, variantValue),
|
||||
max: resolveVariantExpression(expression.max, variantValue),
|
||||
};
|
||||
}
|
||||
return {
|
||||
op: expression.op,
|
||||
left: resolveVariantExpression(expression.left, variantValue),
|
||||
right: resolveVariantExpression(expression.right, variantValue),
|
||||
};
|
||||
}
|
||||
|
||||
function programsForDraft(
|
||||
draft: ConditionalMechanicDraftV1,
|
||||
options: ParseConditionalMechanicDraftOptions,
|
||||
@@ -1002,7 +1098,13 @@ function programsForDraft(
|
||||
}) => ({
|
||||
...outcome,
|
||||
id: outcomeKey,
|
||||
value: variants[variant].expression ?? variants[variant].value,
|
||||
value:
|
||||
variants[variant].expression === undefined
|
||||
? variants[variant].value
|
||||
: resolveVariantExpression(
|
||||
variants[variant].expression,
|
||||
variants[variant].value,
|
||||
),
|
||||
}),
|
||||
),
|
||||
})),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { evaluateMechanicProgram } from './mechanic.js';
|
||||
import {
|
||||
adaptConditionalEffectProgram,
|
||||
type ConditionalEffectClauseRow,
|
||||
@@ -912,4 +913,77 @@ describe('conditional-effect persistence adapter', () => {
|
||||
if: condition,
|
||||
});
|
||||
});
|
||||
it('preserves a variant-value expression in persisted JSON while adapting it to the selected row value', () => {
|
||||
const sourceExpression = {
|
||||
op: 'multiply',
|
||||
left: { ref: { source: 'variant_value' } },
|
||||
right: {
|
||||
op: 'add',
|
||||
left: 1,
|
||||
right: {
|
||||
op: 'divide',
|
||||
left: {
|
||||
ref: {
|
||||
source: 'equipment',
|
||||
path: 'protocolTalentUpgrades',
|
||||
},
|
||||
},
|
||||
right: 5,
|
||||
},
|
||||
},
|
||||
};
|
||||
const serializedExpression = JSON.stringify(sourceExpression);
|
||||
const persistedOutcome = outcome({
|
||||
state_pool_id: null,
|
||||
value: 7.25,
|
||||
value_expression_json: serializedExpression,
|
||||
value_scaling: 'flat',
|
||||
application_phase: 'on_event',
|
||||
});
|
||||
const result = adaptConditionalEffectProgram(
|
||||
{
|
||||
rule: rule(),
|
||||
statePools: [],
|
||||
clauses: [
|
||||
clause({
|
||||
trigger_id: null,
|
||||
event_kind: 'tick',
|
||||
parameters_json: '{}',
|
||||
state_pool_id: null,
|
||||
mutations_json: '[]',
|
||||
}),
|
||||
],
|
||||
outcomes: [persistedOutcome],
|
||||
triggers: [],
|
||||
},
|
||||
{ variantKey: 'I' },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ status: 'READY', errors: [] });
|
||||
expect(persistedOutcome.value).toBe(7.25);
|
||||
expect(persistedOutcome.value_expression_json).toBe(serializedExpression);
|
||||
expect(JSON.parse(String(persistedOutcome.value_expression_json))).toEqual(
|
||||
sourceExpression,
|
||||
);
|
||||
expect(result.program?.clauses[0].outcomes?.[0].value).toEqual({
|
||||
op: 'multiply',
|
||||
left: 7.25,
|
||||
right: sourceExpression.right,
|
||||
});
|
||||
|
||||
if (!result.program) return;
|
||||
const runtime = evaluateMechanicProgram(
|
||||
result.program,
|
||||
[{ kind: 'tick', at: 0 }],
|
||||
{ mode: 'custom', encounterSeconds: 1 },
|
||||
undefined,
|
||||
{ protocolTalentUpgrades: 5 },
|
||||
);
|
||||
expect(runtime.status).toBe('OK');
|
||||
expect(runtime.outcomes[0]).toMatchObject({
|
||||
outcomeId: 'outcome-1',
|
||||
value: 14.5,
|
||||
resolvedValue: 14.5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type Predicate,
|
||||
type Reducer,
|
||||
type ScenarioInputDefinition,
|
||||
type ValueReference,
|
||||
} from './mechanic.js';
|
||||
|
||||
type DatabaseValue = string | number | bigint | boolean | null | undefined;
|
||||
@@ -257,6 +258,55 @@ function isNumericExpression(value: unknown): value is NumericExpression {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveVariantExpression(
|
||||
value: unknown,
|
||||
variantValue: number,
|
||||
): NumericExpression | null {
|
||||
if (finite(value)) return value;
|
||||
if (!object(value)) return null;
|
||||
if ('ref' in value) {
|
||||
if (
|
||||
object(value.ref) &&
|
||||
value.ref.source === 'variant_value' &&
|
||||
Object.keys(value.ref).length === 1
|
||||
) {
|
||||
return variantValue;
|
||||
}
|
||||
return isValueReference(value.ref)
|
||||
? { ref: value.ref as ValueReference }
|
||||
: null;
|
||||
}
|
||||
if (value.op === 'floor') {
|
||||
const nested = resolveVariantExpression(value.value, variantValue);
|
||||
return nested === null ? null : { op: 'floor', value: nested };
|
||||
}
|
||||
if (value.op === 'clamp') {
|
||||
const nested = resolveVariantExpression(value.value, variantValue);
|
||||
const min = resolveVariantExpression(value.min, variantValue);
|
||||
const max = resolveVariantExpression(value.max, variantValue);
|
||||
return nested === null || min === null || max === null
|
||||
? null
|
||||
: { op: 'clamp', value: nested, min, max };
|
||||
}
|
||||
if (
|
||||
!['add', 'subtract', 'multiply', 'divide', 'min', 'max'].includes(
|
||||
String(value.op),
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const left = resolveVariantExpression(value.left, variantValue);
|
||||
const right = resolveVariantExpression(value.right, variantValue);
|
||||
return left === null || right === null
|
||||
? null
|
||||
: {
|
||||
op: value.op as
|
||||
'add' | 'subtract' | 'multiply' | 'divide' | 'min' | 'max',
|
||||
left,
|
||||
right,
|
||||
};
|
||||
}
|
||||
|
||||
function isPredicate(value: unknown): value is Predicate {
|
||||
if (!object(value) || typeof value.kind !== 'string') return false;
|
||||
if (value.kind === 'true') return true;
|
||||
@@ -673,11 +723,8 @@ function parseOutcome(
|
||||
) {
|
||||
errors.push(`${path} has an unsupported application phase.`);
|
||||
}
|
||||
let value: NumericExpression = requiredNumber(
|
||||
row.value,
|
||||
`${path} value`,
|
||||
errors,
|
||||
);
|
||||
const variantValue = requiredNumber(row.value, `${path} value`, errors);
|
||||
let value: NumericExpression = variantValue;
|
||||
if (
|
||||
row.value_expression_json !== null &&
|
||||
row.value_expression_json !== undefined &&
|
||||
@@ -689,7 +736,8 @@ function parseOutcome(
|
||||
errors,
|
||||
);
|
||||
if (parsed.ok) {
|
||||
if (isNumericExpression(parsed.value)) value = parsed.value;
|
||||
const resolved = resolveVariantExpression(parsed.value, variantValue);
|
||||
if (resolved !== null) value = resolved;
|
||||
else
|
||||
errors.push(
|
||||
`${path} value expression is not a valid numeric expression.`,
|
||||
|
||||
@@ -19,6 +19,84 @@ const custom = (
|
||||
inputs,
|
||||
});
|
||||
|
||||
const closeAndPersonalProgram = (
|
||||
resolvedVariantValue: number,
|
||||
): MechanicProgram => ({
|
||||
id: 'close-and-personal-engineering',
|
||||
name: 'Close and Personal Engineering',
|
||||
pools: [
|
||||
{
|
||||
key: 'eligible-allies',
|
||||
label: 'Eligible Allies',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 20,
|
||||
},
|
||||
],
|
||||
clauses: [
|
||||
{
|
||||
id: 'sample-eligible-allies',
|
||||
when: 'proximity_snapshot',
|
||||
reducer: {
|
||||
kind: 'derived_scalar',
|
||||
value: {
|
||||
ref: {
|
||||
source: 'event',
|
||||
path: 'values.alliesWithin30MetersWithAgentGrantedBuff',
|
||||
},
|
||||
},
|
||||
},
|
||||
mutations: [
|
||||
{
|
||||
kind: 'derive',
|
||||
pool: 'eligible-allies',
|
||||
value: { ref: { source: 'reducer' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'resolve-engineering',
|
||||
when: 'tick',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'none' }],
|
||||
outcomes: [
|
||||
{
|
||||
id: 'engineering',
|
||||
label: 'Engineering',
|
||||
phase: 'on_event',
|
||||
kind: 'attribute_modifier',
|
||||
scope: 'self',
|
||||
basis: 'flat',
|
||||
attributeId: 'engineering',
|
||||
value: {
|
||||
op: 'multiply',
|
||||
left: {
|
||||
op: 'multiply',
|
||||
left: { ref: { source: 'pool', pool: 'eligible-allies' } },
|
||||
right: resolvedVariantValue,
|
||||
},
|
||||
right: {
|
||||
op: 'add',
|
||||
left: 1,
|
||||
right: {
|
||||
op: 'divide',
|
||||
left: {
|
||||
ref: {
|
||||
source: 'equipment',
|
||||
path: 'protocolTalentUpgrades',
|
||||
},
|
||||
},
|
||||
right: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe('deterministic mechanic probabilities and assigned abilities', () => {
|
||||
it('rolls outcome probability independently with a reproducible seed and preserves a refreshed window', () => {
|
||||
const program: MechanicProgram = {
|
||||
@@ -321,6 +399,23 @@ describe('structured mechanic runtime', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('advertises the optional non-negative integer proximity count without making snapshots strict', () => {
|
||||
const definition = MECHANIC_EVENT_REGISTRY.proximity_snapshot;
|
||||
const countField = definition.fields?.find(
|
||||
({ path }) => path === 'values.alliesWithin30MetersWithAgentGrantedBuff',
|
||||
);
|
||||
|
||||
expect(definition.strictFields).not.toBe(true);
|
||||
expect(countField).toEqual({
|
||||
path: 'values.alliesWithin30MetersWithAgentGrantedBuff',
|
||||
label: 'Allies within 30 meters with an Agent-granted buff',
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
integer: true,
|
||||
});
|
||||
expect(countField?.required).not.toBe(true);
|
||||
});
|
||||
|
||||
it('advertises imported support and equipment-use trigger events', () => {
|
||||
expect(MECHANIC_EVENT_REGISTRY.ally_downed).toMatchObject({
|
||||
key: 'ally_downed',
|
||||
@@ -1306,6 +1401,7 @@ describe('structured mechanic runtime', () => {
|
||||
],
|
||||
custom(),
|
||||
);
|
||||
expect(result.status).toBe('OK');
|
||||
expect(result.finalState[0].value).toBe(2);
|
||||
const maximum = evaluateMechanicProgram(program, [], {
|
||||
mode: 'maximum',
|
||||
@@ -1314,6 +1410,81 @@ describe('structured mechanic runtime', () => {
|
||||
expect(maximum.finalState[0].value).toBe(3);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ count: 0, upgrades: 0, multiplier: 0 },
|
||||
{ count: 3, upgrades: 0, multiplier: 3 },
|
||||
{ count: 3, upgrades: 5, multiplier: 6 },
|
||||
])(
|
||||
'resolves Close-and-Personal-style Engineering for count $count and $upgrades OS upgrades',
|
||||
({ count, upgrades, multiplier }) => {
|
||||
const resolvedVariantValue = 7.25;
|
||||
const result = evaluateMechanicProgram(
|
||||
closeAndPersonalProgram(resolvedVariantValue),
|
||||
[
|
||||
{
|
||||
kind: 'proximity_snapshot',
|
||||
at: 0,
|
||||
values: {
|
||||
alliesWithin30MetersWithAgentGrantedBuff: count,
|
||||
},
|
||||
},
|
||||
{ kind: 'tick', at: 1 },
|
||||
],
|
||||
custom(),
|
||||
undefined,
|
||||
{ protocolTalentUpgrades: upgrades },
|
||||
);
|
||||
|
||||
expect(result.status).toBe('OK');
|
||||
expect(result.finalState).toContainEqual(
|
||||
expect.objectContaining({ pool: 'eligible-allies', value: count }),
|
||||
);
|
||||
expect(
|
||||
result.outcomes.find(({ outcomeId }) => outcomeId === 'engineering'),
|
||||
).toMatchObject({
|
||||
value: resolvedVariantValue * multiplier,
|
||||
resolvedValue: resolvedVariantValue * multiplier,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
count: '2',
|
||||
error:
|
||||
'Allies within 30 meters with an Agent-granted buff must be a number.',
|
||||
},
|
||||
{
|
||||
count: -1,
|
||||
error:
|
||||
'Allies within 30 meters with an Agent-granted buff must be 0 or greater.',
|
||||
},
|
||||
{
|
||||
count: 1.5,
|
||||
error:
|
||||
'Allies within 30 meters with an Agent-granted buff must be an integer.',
|
||||
},
|
||||
])('rejects invalid proximity ally count $count', ({ count, error }) => {
|
||||
const result = evaluateMechanicProgram(
|
||||
closeAndPersonalProgram(7.25),
|
||||
[
|
||||
{
|
||||
kind: 'proximity_snapshot',
|
||||
at: 0,
|
||||
values: {
|
||||
alliesWithin30MetersWithAgentGrantedBuff: count,
|
||||
},
|
||||
},
|
||||
],
|
||||
custom(),
|
||||
undefined,
|
||||
{ protocolTalentUpgrades: 0 },
|
||||
);
|
||||
|
||||
expect(result.status).toBe('INVALID');
|
||||
expect(result.errors).toContain(`Event 1 (Proximity snapshot) ${error}`);
|
||||
});
|
||||
|
||||
it('shares one movement charge pool across generation and consumption clauses', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'mobile-charge',
|
||||
|
||||
@@ -28,6 +28,13 @@ export const MECHANIC_EVENT_KINDS = [
|
||||
'tick',
|
||||
] as const;
|
||||
|
||||
export const PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_VALUE_KEY =
|
||||
'alliesWithin30MetersWithAgentGrantedBuff' as const;
|
||||
export const PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_FIELD =
|
||||
`values.${PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_VALUE_KEY}` as const;
|
||||
export const PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_INPUT =
|
||||
'allies-within-30m-with-agent-granted-buff' as const;
|
||||
|
||||
export type MechanicEventKind = (typeof MECHANIC_EVENT_KINDS)[number];
|
||||
export type MechanicEventDefinition = {
|
||||
key: MechanicEventKind;
|
||||
@@ -39,6 +46,7 @@ export type MechanicEventDefinition = {
|
||||
type: 'boolean' | 'number' | 'string';
|
||||
required?: boolean;
|
||||
minimum?: number;
|
||||
integer?: boolean;
|
||||
}[];
|
||||
strictFields?: boolean;
|
||||
};
|
||||
@@ -308,6 +316,15 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<
|
||||
label: 'Proximity snapshot',
|
||||
description:
|
||||
'A sampled count or state for nearby allies, enemies, and Agent-owned buffs.',
|
||||
fields: [
|
||||
{
|
||||
path: PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_FIELD,
|
||||
label: 'Allies within 30 meters with an Agent-granted buff',
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
integer: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
firing_started: {
|
||||
key: 'firing_started',
|
||||
@@ -2049,6 +2066,13 @@ function validateMechanicEvents(events: MechanicEvent[]) {
|
||||
) {
|
||||
errors.push(`${fieldPath} must be ${field.minimum} or greater.`);
|
||||
}
|
||||
if (
|
||||
field.type === 'number' &&
|
||||
field.integer &&
|
||||
!Number.isInteger(value)
|
||||
) {
|
||||
errors.push(`${fieldPath} must be an integer.`);
|
||||
}
|
||||
}
|
||||
const hasExtraHealth = getPath(event, 'values.hasExtraHealth');
|
||||
const currentExtraHealth = getPath(event, 'values.currentExtraHealth');
|
||||
|
||||
@@ -9,15 +9,18 @@
|
||||
blankConditionalMechanicDraft,
|
||||
currentHealthPercentBandPredicate,
|
||||
eventFieldComparisonPredicate,
|
||||
eventFieldNumericExpression,
|
||||
mechanicTargetingForMode,
|
||||
mechanicTargetingMode,
|
||||
parseConditionalMechanicDraft,
|
||||
readCurrentHealthPercentBand,
|
||||
readEventFieldComparison,
|
||||
readEventFieldNumericExpression,
|
||||
registeredEventFields,
|
||||
readStatePoolThreshold,
|
||||
statePoolThresholdPredicate,
|
||||
type ConditionalMechanicClauseDraft,
|
||||
type ConditionalNumericExpression,
|
||||
type ConditionalMechanicDraftV1,
|
||||
type ConditionalMechanicOutcomeDraft,
|
||||
type ConditionalMechanicPoolDraft,
|
||||
@@ -31,6 +34,7 @@
|
||||
formatMechanicProgram,
|
||||
MECHANIC_EVENT_KINDS,
|
||||
MECHANIC_EVENT_REGISTRY,
|
||||
PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_FIELD,
|
||||
type ClauseMutation,
|
||||
type MechanicOutcomeScope,
|
||||
type NumericExpression,
|
||||
@@ -408,6 +412,40 @@
|
||||
updateClause(index, { reducer });
|
||||
}
|
||||
|
||||
function derivedScalarModel(clause: ConditionalMechanicClauseDraft) {
|
||||
if (clause.reducer.kind !== 'derived_scalar') return 'static';
|
||||
if (typeof clause.reducer.value === 'number') return 'static';
|
||||
return readEventFieldNumericExpression(clause.reducer.value) ? 'event' : 'advanced';
|
||||
}
|
||||
|
||||
function numericEventFields(clause: ConditionalMechanicClauseDraft) {
|
||||
return registeredEventFields(clause.event).filter((field) => field.type === 'number');
|
||||
}
|
||||
|
||||
function setDerivedScalarModel(clauseIndex: number, model: string) {
|
||||
const clause = rule.clauses[clauseIndex];
|
||||
if (clause.reducer.kind !== 'derived_scalar') return;
|
||||
if (model === 'static') {
|
||||
updateClause(clauseIndex, { reducer: { kind: 'derived_scalar', value: 1 } });
|
||||
return;
|
||||
}
|
||||
const field = numericEventFields(clause)[0];
|
||||
if (model === 'event' && field) {
|
||||
updateClause(clauseIndex, {
|
||||
reducer: {
|
||||
kind: 'derived_scalar',
|
||||
value: eventFieldNumericExpression(field.path)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setDerivedScalarEventField(clauseIndex: number, path: string) {
|
||||
updateClause(clauseIndex, {
|
||||
reducer: { kind: 'derived_scalar', value: eventFieldNumericExpression(path) }
|
||||
});
|
||||
}
|
||||
|
||||
function updateOutcome(clauseIndex: number, outcomeIndex: number, patch: Partial<ConditionalMechanicOutcomeDraft>) {
|
||||
const outcomes = [...rule.clauses[clauseIndex].outcomes];
|
||||
outcomes[outcomeIndex] = { ...outcomes[outcomeIndex], ...patch };
|
||||
@@ -517,6 +555,10 @@
|
||||
const eventValueOptions = [
|
||||
{ value: 'values.enemyCount', label: 'ENEMY COUNT' },
|
||||
{ value: 'values.allyCount', label: 'ALLY COUNT' },
|
||||
{
|
||||
value: PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_FIELD,
|
||||
label: 'AGENT-BUFFED ALLIES ≤30M'
|
||||
},
|
||||
{ value: 'values.magazineRemaining', label: 'ROUNDS LEFT' },
|
||||
{ value: 'values.magazinePercent', label: 'MAGAZINE %' },
|
||||
{ value: 'values.distanceMeters', label: 'DISTANCE' }
|
||||
@@ -534,10 +576,10 @@
|
||||
};
|
||||
}
|
||||
|
||||
function protocolUpgradeExpression(value: number): NumericExpression {
|
||||
function protocolUpgradeExpression(): ConditionalNumericExpression {
|
||||
return {
|
||||
op: 'multiply',
|
||||
left: value,
|
||||
left: { ref: { source: 'variant_value' } },
|
||||
right: {
|
||||
op: 'add',
|
||||
left: 1,
|
||||
@@ -578,17 +620,17 @@
|
||||
typeof expression === 'object'
|
||||
&& 'op' in expression
|
||||
&& expression.op === 'multiply'
|
||||
&& typeof expression.left === 'number'
|
||||
&& typeof expression.right === 'object'
|
||||
) {
|
||||
if ('ref' in expression.right) {
|
||||
if (typeof expression.left === 'number' && 'ref' in expression.right) {
|
||||
if (expression.right.ref.source === 'event') return 'event';
|
||||
if (expression.right.ref.source === 'scenario') return 'scenario';
|
||||
if (expression.right.ref.source === 'reducer') return 'reducer';
|
||||
if (expression.right.ref.source === 'attribute') return 'attribute_value';
|
||||
}
|
||||
if (
|
||||
'op' in expression.right
|
||||
typeof expression.left === 'number'
|
||||
&& 'op' in expression.right
|
||||
&& expression.right.op === 'divide'
|
||||
&& typeof expression.right.left === 'object'
|
||||
&& 'ref' in expression.right.left
|
||||
@@ -596,7 +638,13 @@
|
||||
&& expression.right.right === 100
|
||||
) return 'attribute';
|
||||
if (
|
||||
'op' in expression.right
|
||||
(typeof expression.left === 'number'
|
||||
|| (
|
||||
typeof expression.left === 'object'
|
||||
&& 'ref' in expression.left
|
||||
&& expression.left.ref.source === 'variant_value'
|
||||
))
|
||||
&& 'op' in expression.right
|
||||
&& expression.right.op === 'add'
|
||||
&& typeof expression.right.right === 'object'
|
||||
&& 'op' in expression.right.right
|
||||
@@ -657,7 +705,7 @@
|
||||
model: OutcomeValueModel,
|
||||
value: number,
|
||||
reference = ''
|
||||
): NumericExpression | undefined {
|
||||
): ConditionalNumericExpression | undefined {
|
||||
if (model === 'event') return referenceExpression('event', reference || 'values.enemyCount', value);
|
||||
if (model === 'attribute') return reference
|
||||
? attributePercentExpression(value, reference)
|
||||
@@ -671,7 +719,7 @@
|
||||
if (model === 'reducer') {
|
||||
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
|
||||
}
|
||||
if (model === 'protocol_upgrades') return protocolUpgradeExpression(value);
|
||||
if (model === 'protocol_upgrades') return protocolUpgradeExpression();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -698,7 +746,7 @@
|
||||
updateOutcome(clauseIndex, outcomeIndex, { variants });
|
||||
}
|
||||
|
||||
function replaceExpressionBase(expression: NumericExpression, value: number): NumericExpression {
|
||||
function replaceExpressionBase(expression: ConditionalNumericExpression, value: number): ConditionalNumericExpression {
|
||||
const model = outcomeValueModel({
|
||||
key: 'temporary',
|
||||
label: 'Temporary',
|
||||
@@ -743,7 +791,7 @@
|
||||
if (model === 'reducer') {
|
||||
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
|
||||
}
|
||||
if (model === 'protocol_upgrades') return protocolUpgradeExpression(value);
|
||||
if (model === 'protocol_upgrades') return protocolUpgradeExpression();
|
||||
return expression;
|
||||
}
|
||||
|
||||
@@ -802,7 +850,7 @@
|
||||
updateClause(clauseIndex, { mutations });
|
||||
}
|
||||
|
||||
function updateMutation(clauseIndex: number, mutationIndex: number, kind: 'add' | 'set' | 'consume' | 'clear', pool?: string, value?: number) {
|
||||
function updateMutation(clauseIndex: number, mutationIndex: number, kind: 'add' | 'set' | 'consume' | 'derive' | 'clear', pool?: string, value?: number) {
|
||||
const mutations = [...rule.clauses[clauseIndex].mutations];
|
||||
const current = mutations[mutationIndex];
|
||||
const nextPool = pool ?? ('pool' in current ? current.pool : rule.pools[0]?.key ?? '');
|
||||
@@ -812,7 +860,9 @@
|
||||
: undefined;
|
||||
mutations[mutationIndex] = kind === 'clear'
|
||||
? { kind, pool: nextPool, entity }
|
||||
: { kind, pool: nextPool, value: value ?? ('value' in current && typeof current.value === 'number' ? current.value : 1), entity };
|
||||
: kind === 'derive'
|
||||
? { kind, pool: nextPool, value: { ref: { source: 'reducer' } }, entity }
|
||||
: { kind, pool: nextPool, value: value ?? ('value' in current && typeof current.value === 'number' ? current.value : 1), entity };
|
||||
updateClause(clauseIndex, { mutations });
|
||||
}
|
||||
|
||||
@@ -1134,7 +1184,29 @@
|
||||
{:else if clause.reducer.kind === 'periodic'}
|
||||
<label class="field">INTERVAL · SECONDS<input type="number" min="0.01" step="any" value={typeof clause.reducer.intervalSeconds === 'number' ? clause.reducer.intervalSeconds : 1} oninput={(event) => setReducerNumber(clauseIndex,'intervalSeconds',event.currentTarget.value)}/></label>
|
||||
{:else if clause.reducer.kind === 'derived_scalar'}
|
||||
<label class="field">DERIVED VALUE<input type="number" step="any" value={typeof clause.reducer.value === 'number' ? clause.reducer.value : 1} oninput={(event) => setReducerNumber(clauseIndex,'value',event.currentTarget.value)}/></label>
|
||||
<SegmentedChoice
|
||||
legend="DERIVED VALUE SOURCE"
|
||||
name={`${idPrefix}-derived-source-${clauseIndex}`}
|
||||
options={[
|
||||
{value:'static',label:'STATIC'},
|
||||
{value:'event',label:'EVENT FIELD',disabled:numericEventFields(clause).length === 0},
|
||||
...(derivedScalarModel(clause) === 'advanced' ? [{value:'advanced',label:'ADVANCED',disabled:true}] : [])
|
||||
]}
|
||||
value={derivedScalarModel(clause)}
|
||||
onchange={(values) => setDerivedScalarModel(clauseIndex,values[0])}
|
||||
/>
|
||||
{#if derivedScalarModel(clause) === 'event'}
|
||||
<SegmentedChoice
|
||||
legend="NUMERIC EVENT FIELD"
|
||||
name={`${idPrefix}-derived-event-${clauseIndex}`}
|
||||
options={numericEventFields(clause).map((field) => ({value:field.path,label:field.label.toUpperCase()}))}
|
||||
value={readEventFieldNumericExpression(clause.reducer.value) ?? ''}
|
||||
columns={Math.min(3,numericEventFields(clause).length)}
|
||||
onchange={(values) => setDerivedScalarEventField(clauseIndex,values[0])}
|
||||
/>
|
||||
{:else if derivedScalarModel(clause) === 'static'}
|
||||
<label class="field">DERIVED VALUE<input type="number" step="any" value={typeof clause.reducer.value === 'number' ? clause.reducer.value : 1} oninput={(event) => setReducerNumber(clauseIndex,'value',event.currentTarget.value)}/></label>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1159,9 +1231,9 @@
|
||||
<SegmentedChoice
|
||||
legend="OPERATION"
|
||||
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
|
||||
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'clear',label:'CLEAR'}]}
|
||||
value={mutation.kind === 'none' || mutation.kind === 'derive' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
|
||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'clear')}
|
||||
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'derive',label:'DERIVED VALUE'},{value:'clear',label:'CLEAR'}]}
|
||||
value={mutation.kind === 'none' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
|
||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'derive'|'clear')}
|
||||
/>
|
||||
<SegmentedChoice
|
||||
legend="STATE POOL"
|
||||
@@ -1169,10 +1241,12 @@
|
||||
options={rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))}
|
||||
value={'pool' in mutation ? mutation.pool : rule.pools[0]?.key ?? ''}
|
||||
columns={Math.min(4,Math.max(1,rule.pools.length))}
|
||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'clear' ? 'clear' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])}
|
||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'clear' ? 'clear' : mutation.kind === 'derive' ? 'derive' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])}
|
||||
/>
|
||||
{#if mutation.kind !== 'clear'}
|
||||
{#if mutation.kind !== 'clear' && mutation.kind !== 'derive'}
|
||||
<label class="field">VALUE<input type="number" step="any" value={'value' in mutation && typeof mutation.value === 'number' ? mutation.value : 1} oninput={(event) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',undefined,Number(event.currentTarget.value))}/></label>
|
||||
{:else if mutation.kind === 'derive'}
|
||||
<div class="branch-readout"><span>VALUE</span><strong>DERIVED SCALAR</strong></div>
|
||||
{/if}
|
||||
{#if 'pool' in mutation && rule.pools.find((pool) => pool.key === mutation.pool)?.scope === 'target'}
|
||||
<SegmentedChoice
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('AI mechanic authoring prompt', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
|
||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(11);
|
||||
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
|
||||
expect.arrayContaining(['delivery', 'durationSeconds']),
|
||||
);
|
||||
@@ -136,7 +136,7 @@ describe('AI mechanic authoring prompt', () => {
|
||||
]),
|
||||
);
|
||||
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
||||
'Prompt contract version: 10',
|
||||
'Prompt contract version: 11',
|
||||
);
|
||||
|
||||
const advertised = structuredClone(
|
||||
@@ -185,7 +185,7 @@ describe('AI mechanic authoring prompt', () => {
|
||||
expect(parsed.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('advertises the canonical Extra Health change event contract in version 10', () => {
|
||||
it('advertises the canonical Extra Health change event contract in version 11', () => {
|
||||
const input = JSON.parse(
|
||||
buildAiMechanicAuthoringInput({
|
||||
request,
|
||||
@@ -196,9 +196,9 @@ describe('AI mechanic authoring prompt', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
|
||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(11);
|
||||
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
||||
'Prompt contract version: 10',
|
||||
'Prompt contract version: 11',
|
||||
);
|
||||
expect(input.canonicalCatalog.events).toContainEqual({
|
||||
key: 'extra_health_changed',
|
||||
@@ -325,7 +325,7 @@ describe('AI mechanic authoring prompt', () => {
|
||||
expect(parsed.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('advertises reproducible outcome and clause proc probabilities in version 10', () => {
|
||||
it('advertises reproducible outcome and clause proc probabilities in version 11', () => {
|
||||
const input = JSON.parse(
|
||||
buildAiMechanicAuthoringInput({
|
||||
request,
|
||||
@@ -336,7 +336,7 @@ describe('AI mechanic authoring prompt', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
|
||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(11);
|
||||
expect(input.canonicalCatalog.grammar.clause.optional).toEqual(
|
||||
expect.arrayContaining([
|
||||
'procChancePercent',
|
||||
@@ -489,4 +489,62 @@ describe('AI mechanic authoring prompt', () => {
|
||||
});
|
||||
expect(parsed.ok).toBe(true);
|
||||
});
|
||||
it('advertises draft variant-value scaling and the typed proximity input in version 11', () => {
|
||||
const input = JSON.parse(
|
||||
buildAiMechanicAuthoringInput({
|
||||
request: {
|
||||
...request,
|
||||
context: {
|
||||
...request.context,
|
||||
sourceName: 'Close and Personal',
|
||||
sourceDescription:
|
||||
'Increase Weapon Damage by (1)% plus (2)% for each nearby ally with a buff granted by you.',
|
||||
},
|
||||
},
|
||||
attributes: [],
|
||||
events: [MECHANIC_EVENT_REGISTRY.proximity_snapshot],
|
||||
actions: [],
|
||||
recipeKeys: [],
|
||||
}),
|
||||
);
|
||||
const grammar = input.canonicalCatalog.grammar;
|
||||
const proximityEvent = input.canonicalCatalog.events[0];
|
||||
|
||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(11);
|
||||
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
||||
'Prompt contract version: 11',
|
||||
);
|
||||
expect(grammar.valueReferences).toContain('variant_value');
|
||||
expect(grammar.draftOutcomeExpressionReferences).toEqual(['variant_value']);
|
||||
expect(grammar.referenceShapes).toContainEqual({
|
||||
ref: { source: 'variant_value' },
|
||||
});
|
||||
expect(proximityEvent.fields).toContainEqual({
|
||||
path: 'values.alliesWithin30MetersWithAgentGrantedBuff',
|
||||
label: 'Allies within 30 meters with an Agent-granted buff',
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
integer: true,
|
||||
});
|
||||
expect(proximityEvent.strictFields).toBeUndefined();
|
||||
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toEqual(
|
||||
expect.stringContaining('unresolved numbered source placeholder'),
|
||||
);
|
||||
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toEqual(
|
||||
expect.stringContaining('{ref:{source:"variant_value"}}'),
|
||||
);
|
||||
expect(input.examplesAndRules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('only inside an outcome variant expression'),
|
||||
expect.stringContaining('outcome variants[variantKey].value'),
|
||||
expect.stringContaining(
|
||||
'{ref:{source:"event",path:"values.alliesWithin30MetersWithAgentGrantedBuff"}}',
|
||||
),
|
||||
expect.stringContaining(
|
||||
'{key:"allies-within-30m-with-agent-granted-buff"',
|
||||
),
|
||||
expect.stringContaining('readiness:"ready",min:0'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES } from '$lib/calculations/mec
|
||||
import { MECHANIC_COMBAT_REFERENCE_REGISTRY } from '$lib/calculations/mechanic';
|
||||
import type { AiMechanicAttribute } from './mechanic-authoring-context';
|
||||
|
||||
export const AI_MECHANIC_PROMPT_VERSION = 10;
|
||||
export const AI_MECHANIC_PROMPT_VERSION = 11;
|
||||
|
||||
const mechanicsGrammar = {
|
||||
version: 1,
|
||||
@@ -83,6 +83,7 @@ const mechanicsGrammar = {
|
||||
'attribute',
|
||||
'reducer',
|
||||
'pool',
|
||||
'variant_value',
|
||||
],
|
||||
combatReferences: MECHANIC_COMBAT_REFERENCE_REGISTRY,
|
||||
numericExpressionOperators: [
|
||||
@@ -95,7 +96,9 @@ const mechanicsGrammar = {
|
||||
'clamp',
|
||||
'floor',
|
||||
],
|
||||
draftOutcomeExpressionReferences: ['variant_value'],
|
||||
referenceShapes: [
|
||||
{ ref: { source: 'variant_value' } },
|
||||
{ ref: { source: 'event', path: 'values.distanceMeters' } },
|
||||
{ ref: { source: 'scenario', key: 'input-key' } },
|
||||
{ ref: { source: 'combat', path: 'currentHealth', timing: 'current' } },
|
||||
@@ -498,6 +501,7 @@ Security and scope:
|
||||
- Use only the supplied event, action, recipe, attribute, scope, reference, expression, reducer, mutation, and outcome catalogs.
|
||||
- Never invent an Attribute ID, event, field path, source variant, or runtime behavior.
|
||||
- Ask focused questions when different answers would change the calculation. Do not guess material cadence, duration, target, stacking, reset, scope, damage basis, or value behavior.
|
||||
- An unresolved numbered source placeholder is a material missing value. Ask the human for the exact numeric value for every affected source variant; never infer or guess it.
|
||||
- If the behavior cannot be represented by the supplied grammar, return capability_gap with implementation-ready developer changes.
|
||||
|
||||
Response rules:
|
||||
@@ -507,6 +511,7 @@ Response rules:
|
||||
- All keys must be stable lower-case identifiers using letters, digits, dashes, or underscores and beginning with a letter.
|
||||
- A draft must define exactly the supplied value variants for every outcome.
|
||||
- The numeric variant value is the human-facing base value. Use expression only when runtime scaling or a live event/scenario/equipment value changes it.
|
||||
- Put each clarified placeholder answer in the corresponding outcome variant value as its human-facing value. When that value participates in a runtime expression, reference it exactly as {ref:{source:"variant_value"}}; never duplicate the numeric literal in the expression. variant_value is draft-only and valid only inside an outcome variant expression.
|
||||
- Use attribute_modifier only with an exact supplied Attribute ID.
|
||||
- Use direct_damage, restoration, or resource_cost rather than a fabricated Attribute when that is the actual mechanic.
|
||||
- Keep assumptions explicit. Use warnings for representable limitations that do not require code changes.
|
||||
@@ -551,9 +556,14 @@ export function buildAiMechanicAuthoringInput(input: {
|
||||
'A target-scoped pool owns an independent value and expiry timer for each entity. Bind hit-created target state with entity:{kind:"event_target"}; do not collapse different enemies into one Agent-scoped pool.',
|
||||
'Writing a target-scoped pool refreshes only that target entity timer. An on_exit outcome runs when that entity expires and uses the entity previous state for stack bands and perStack multiplication, even though the stored pool value has already reset.',
|
||||
'When an outcome value is exactly a live Attribute amount, use the Attribute reference directly as the variant expression. Divide by 100 only when a displayed percentage is being used as a multiplier.',
|
||||
'variant_value is draft-only and may appear only inside an outcome variant expression, with the exact shape {ref:{source:"variant_value"}}. It resolves to that same variant object’s human-facing numeric value at runtime.',
|
||||
'If source wording contains an unresolved numbered placeholder such as {1} or {2}, return clarification_required and ask for the exact numeric answer for every affected source variant. Never guess a placeholder value.',
|
||||
'After clarification, put each answer in the corresponding outcome variants[variantKey].value. If a runtime-scaled expression uses that answer, reference {ref:{source:"variant_value"}} rather than duplicating the numeric literal.',
|
||||
'A weapon_hit may compare values.aiming with true only when the wording requires aiming down sights at hit resolution. Missing aiming evidence does not match; do not replace it with a scenario assumption.',
|
||||
'An explosion centered on the hit target uses target-scoped direct_damage with targeting kind event_target_area, center event_target, validTargets enemies, and the stated positive radiusMeters. The runtime applies one equal damage emission to each authoritative valid enemy at or inside the radius. Do not use legacy within_radius metadata for explosion damage.',
|
||||
'Precise Trauma is representable as one target-scoped stack pool with a one-second expiry: body shots add one stack, headshots add two, and one on_exit direct-damage outcome uses the expiring target previous stack count. This models the stated one-second debuff without inventing generic repeated target ticks.',
|
||||
'For proximity_snapshot, the optional values.alliesWithin30MetersWithAgentGrantedBuff field is a non-negative integer count of distinct allied Agents at or within 30 meters who possess at least one buff granted by the source Agent. Multiple qualifying buffs on one ally still count that ally once. Read it with {ref:{source:"event",path:"values.alliesWithin30MetersWithAgentGrantedBuff"}}.',
|
||||
'When loadout timelines must supply values.alliesWithin30MetersWithAgentGrantedBuff, define the ready numeric scenario input exactly as {key:"allies-within-30m-with-agent-granted-buff",label:"Allies within 30 meters with an Agent-granted buff",kind:"number",readiness:"ready",min:0}. The loadout timeline copies that resolved count into each proximity_snapshot event, and runtime event validation rejects non-integers.',
|
||||
'OS Protocol upgrade scaling may use equipment.protocolTalentUpgrades only when allowProtocolUpgradeScaling is true.',
|
||||
'Assigned-ability scope may be used only when allowAssignedAbility is true.',
|
||||
'When allowAssignedAbility is true, a kill or assist that must occur while the source item\'s assigned ability is active uses enemy_killed or assist with {source:"event",path:"values.assignedAbilityActive"} compared to true. This field is optional on the event; a missing value does not match.',
|
||||
|
||||
@@ -502,16 +502,12 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
case 'skill-chips':
|
||||
countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE item.active=1${searchClause}`;
|
||||
rowsSql = `SELECT item.*,bonus.min_value,bonus.max_value,attributes.name AS bonus_stat,attributes.unit AS bonus_unit,
|
||||
talents.name AS talent_name,abilities.name AS ability_name,specializations.name AS specialization_name,
|
||||
(SELECT COUNT(*) FROM skill_chip_primary_options) AS primary_count,
|
||||
(SELECT COUNT(*) FROM skill_chip_secondary_options) AS secondary_count,
|
||||
talents.name AS talent_name,talents.description_template AS talent_description,
|
||||
icons.updated_at AS icon_updated_at,render.id AS render_id,render.updated_at AS render_updated_at
|
||||
FROM skill_chip_sets item
|
||||
LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id
|
||||
LEFT JOIN stat_attributes attributes ON attributes.id=bonus.stat_attribute_id
|
||||
LEFT JOIN skill_chip_talents talents ON talents.skill_chip_set_id=item.id
|
||||
LEFT JOIN specialization_abilities abilities ON abilities.id=item.specialization_ability_id
|
||||
LEFT JOIN specializations ON specializations.id=abilities.specialization_id
|
||||
LEFT JOIN game_data_icons icons ON icons.entity_type='skill_chip' AND icons.entity_id=item.id
|
||||
LEFT JOIN managed_images render ON render.source_entity_type='skill_chip_render' AND render.source_entity_id=item.id
|
||||
WHERE item.active=1${searchClause}
|
||||
@@ -1035,23 +1031,12 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
iconUrl: iconUrl('skill_chip', row.id, row.icon_updated_at),
|
||||
badges: [{ label: 'SUPERIOR' }, { label: 'HIGH-END' }],
|
||||
facts: [
|
||||
{
|
||||
label: 'SPECIALIZATION ABILITY',
|
||||
value: row.ability_name
|
||||
? `${row.ability_name} · ${row.specialization_name}`
|
||||
: 'UNASSIGNED',
|
||||
},
|
||||
{
|
||||
label: '2-PIECE BONUS',
|
||||
value: row.bonus_stat
|
||||
? `${row.bonus_stat} · ${range(row.min_value, row.max_value, row.bonus_unit)}`
|
||||
: 'NOT CONFIGURED',
|
||||
},
|
||||
{ label: 'PRIMARY POOL', value: `${row.primary_count} OPTIONS` },
|
||||
{
|
||||
label: 'SECONDARY POOL',
|
||||
value: `${row.secondary_count} OPTIONS`,
|
||||
},
|
||||
],
|
||||
lines: [
|
||||
{
|
||||
@@ -1059,6 +1044,9 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
value: list(row.talent_name, 'NOT CONFIGURED'),
|
||||
},
|
||||
],
|
||||
description: row.talent_description
|
||||
? String(row.talent_description)
|
||||
: undefined,
|
||||
};
|
||||
case 'specializations':
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user