feat: add authored mechanic contracts

This commit is contained in:
jb
2026-08-26 23:50:03 -04:00
parent b53e296ef5
commit 98f7f51e46
8 changed files with 4272 additions and 6 deletions
@@ -0,0 +1,283 @@
import { describe, expect, it } from 'vitest';
import {
materializeMechanicAuthoredDescription,
mechanicAuthoredFingerprintPayload,
parseMechanicAuthoredSource,
type MechanicAuthoredSource,
} from './mechanic-authored-description.js';
const talentSource = (): MechanicAuthoredSource => ({
version: 1,
owner: { type: 'talent', id: 'talent-1', scope: 'standard' },
template: 'Gain {1} for {duration}. Gain {1} again, up to {max_stacks}.',
variants: ['I', 'II'].map((key, index) => ({
key,
materialized: `Gain ${10 + index}% for 5 seconds. Gain ${10 + index}% again, up to 3.`,
slots: [
{ key: 'max_stacks', value: '3', order: 2 },
{ key: '1', value: `${10 + index}%`, order: 0, unit: 'percent' },
{ key: 'duration', value: '5 seconds', order: 1, unit: 'seconds' },
],
})),
});
describe('mechanic authored descriptions', () => {
it('materializes repeated strict tokens for ordered Talent variants', () => {
expect(materializeMechanicAuthoredDescription(talentSource())).toEqual({
ok: true,
variants: talentSource().variants.map((variant) => ({
key: variant.key,
text: variant.materialized,
slots: variant.slots,
})),
});
});
it.each([
{
owner: { type: 'talent', id: 'exotic', scope: 'exotic' } as const,
keys: ['I'],
},
{
owner: {
type: 'talent',
id: 'exotic',
scope: 'exotic',
hasTierII: true,
} as const,
keys: ['I', 'II'],
},
{
owner: { type: 'os_protocol_talent', id: 'protocol' } as const,
keys: ['base'],
},
{
owner: { type: 'skill_chip_talent', id: 'chip' } as const,
keys: ['minimum', 'maximum'],
},
])('enforces the $owner.type variant contract', ({ owner, keys }) => {
const source: MechanicAuthoredSource = {
version: 1,
owner,
template: 'Gain {1}.',
variants: keys.map((key) => ({
key,
materialized: 'Gain 10%.',
slots: [{ key: '1', value: '10%', order: 0 }],
})),
};
expect(materializeMechanicAuthoredDescription(source).ok).toBe(true);
});
it('requires Specialization rank source identity', () => {
const result = materializeMechanicAuthoredDescription({
version: 1,
owner: { type: 'specialization_tree_rank', id: 'rank-1' },
template: 'Gain {1}.',
variants: [
{
key: 'base',
materialized: 'Gain 10%.',
slots: [{ key: '1', value: '10%', order: 0 }],
},
],
});
expect(result).toMatchObject({
ok: false,
errors: [{ code: 'invalid_specialization_source' }],
});
});
it('rejects malformed and slot-injected placeholders', () => {
const malformed = talentSource();
malformed.template = 'Gain {1.';
malformed.variants.forEach((variant) => {
variant.materialized = 'Gain {1.';
variant.slots = [];
});
expect(materializeMechanicAuthoredDescription(malformed)).toMatchObject({
ok: false,
errors: expect.arrayContaining([
expect.objectContaining({ code: 'unknown_token' }),
]),
});
const injected = talentSource();
injected.variants[0].slots.find((slot) => slot.key === '1')!.value =
'{raw}';
injected.variants[0].materialized =
'Gain {raw} for 5 seconds. Gain {raw} again, up to 3.';
expect(materializeMechanicAuthoredDescription(injected)).toMatchObject({
ok: false,
errors: expect.arrayContaining([
expect.objectContaining({ code: 'invalid_slot' }),
expect.objectContaining({ code: 'unresolved_token' }),
]),
});
});
it('rejects unknown, unresolved, unused, and mismatched authored values', () => {
const source = talentSource();
source.template = 'Gain {1} for {cooldown}.';
source.variants[0].slots.push({ key: '2', value: 'unused', order: 4 });
const result = materializeMechanicAuthoredDescription(source);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(new Set(result.errors.map((error) => error.code))).toEqual(
new Set([
'unknown_token',
'unresolved_token',
'unused_slot',
'materialization_mismatch',
]),
);
});
it('rejects a missing required slot rather than retaining its placeholder', () => {
const source = talentSource();
source.variants[0].slots = source.variants[0].slots.filter(
(slot) => slot.key !== 'duration',
);
const result = materializeMechanicAuthoredDescription(source);
expect(result).toMatchObject({
ok: false,
errors: expect.arrayContaining([
expect.objectContaining({ code: 'unresolved_token' }),
]),
});
});
it('preserves exact authored whitespace and materialized prose', () => {
const source = talentSource();
source.template = ' Gain {1}.\nNext line. ';
for (const variant of source.variants) {
const value = variant.slots.find((slot) => slot.key === '1')?.value;
variant.slots = variant.slots.filter((slot) => slot.key === '1');
variant.materialized = ` Gain ${value}.\nNext line. `;
}
const result = materializeMechanicAuthoredDescription(source);
expect(result.ok && result.variants[0].text).toBe(
' Gain 10%.\nNext line. ',
);
});
it('parses unknown source values without throwing on malformed input', () => {
expect(parseMechanicAuthoredSource(null)).toMatchObject({
ok: false,
errors: [{ code: 'invalid_source' }],
});
expect(
parseMechanicAuthoredSource({
...talentSource(),
owner: {
type: new String('os_protocol_talent'),
id: 'protocol-1',
},
}),
).toMatchObject({ ok: false, errors: [{ code: 'invalid_source' }] });
expect(
parseMechanicAuthoredSource({
...talentSource(),
variants: [{ key: 'I', materialized: 10, slots: [] }],
}),
).toMatchObject({ ok: false, errors: [{ code: 'invalid_source' }] });
expect(parseMechanicAuthoredSource(talentSource())).toMatchObject({
ok: true,
});
});
it('rejects malformed typed values and whitespace-only authored content', () => {
expect(
materializeMechanicAuthoredDescription({
...talentSource(),
owner: {
type: 'talent',
id: 'talent-1',
scope: 'exotic',
hasTierII: 'false',
},
}),
).toMatchObject({ ok: false, errors: [{ code: 'invalid_source' }] });
const whitespace = talentSource();
whitespace.template = ' ';
whitespace.variants.forEach((variant) => {
variant.materialized = ' ';
variant.slots = [];
});
expect(materializeMechanicAuthoredDescription(whitespace)).toMatchObject({
ok: false,
errors: expect.arrayContaining([
expect.objectContaining({ code: 'invalid_template' }),
]),
});
});
it('strictly parses Talent Tier II and unknown source fields', () => {
expect(
parseMechanicAuthoredSource({
...talentSource(),
owner: {
type: 'talent',
id: 'talent-1',
scope: 'exotic',
hasTierII: 'false',
},
}),
).toMatchObject({ ok: false, errors: [{ code: 'invalid_source' }] });
expect(
parseMechanicAuthoredSource({ ...talentSource(), unexpected: true }),
).toMatchObject({ ok: false, errors: [{ code: 'invalid_source' }] });
});
it('normalizes omitted and false optional Talent Tier II flags identically', () => {
const omitted = talentSource();
const explicit = talentSource();
explicit.owner = {
type: 'talent',
id: 'talent-1',
scope: 'standard',
hasTierII: false,
};
expect(mechanicAuthoredFingerprintPayload(omitted)).toEqual(
mechanicAuthoredFingerprintPayload(explicit),
);
});
it('reports unsupported parser contracts specifically', () => {
expect(
parseMechanicAuthoredSource({ ...talentSource(), version: 2 }),
).toMatchObject({
ok: false,
errors: [{ code: 'unsupported_contract', path: 'version' }],
});
});
it('rejects duplicate slot ordering', () => {
const source = talentSource();
source.variants[0].slots[1].order = source.variants[0].slots[0].order;
const result = materializeMechanicAuthoredDescription(source);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'invalid_slot' }),
]),
);
});
it('creates a versioned canonical fingerprint payload with ordered slots', () => {
expect(mechanicAuthoredFingerprintPayload(talentSource())).toMatchObject({
kind: 'mechanic_authored_source',
version: 1,
owner: { type: 'talent', id: 'talent-1' },
variants: [
{
key: 'I',
slots: [{ key: '1' }, { key: 'duration' }, { key: 'max_stacks' }],
},
{ key: 'II' },
],
});
});
});
@@ -0,0 +1,431 @@
export const MECHANIC_AUTHORED_CONTRACT_VERSION = 1 as const;
export type MechanicAuthoredOwner =
| {
type: 'talent';
id: string;
scope: 'standard' | 'exotic';
hasTierII?: boolean;
}
| { type: 'os_protocol_talent'; id: string }
| { type: 'skill_chip_talent'; id: string }
| { type: 'specialization_tree_rank'; id: string };
export type MechanicAuthoredSlot = {
key: string;
value: string;
order: number;
unit?: string;
attributeId?: string;
};
export type MechanicAuthoredVariant = {
key: string;
materialized: string;
slots: MechanicAuthoredSlot[];
};
export type SpecializationAuthoredInputs = {
rankDescription: string;
effectId: string;
effectTemplate: string;
};
export type MechanicAuthoredSource = {
version: typeof MECHANIC_AUTHORED_CONTRACT_VERSION;
owner: MechanicAuthoredOwner;
template: string;
variants: MechanicAuthoredVariant[];
specialization?: SpecializationAuthoredInputs;
};
export type MaterializedMechanicVariant = {
key: string;
text: string;
slots: MechanicAuthoredSlot[];
};
export type MechanicAuthoredDescriptionError = {
code:
| 'invalid_source'
| 'unsupported_contract'
| 'invalid_owner'
| 'invalid_template'
| 'invalid_variant'
| 'invalid_slot'
| 'unknown_token'
| 'unresolved_token'
| 'unused_slot'
| 'materialization_mismatch'
| 'invalid_specialization_source';
path: string;
message: string;
};
export type MechanicAuthoredDescriptionResult =
| { ok: true; variants: MaterializedMechanicVariant[] }
| { ok: false; errors: MechanicAuthoredDescriptionError[] };
export type ParsedMechanicAuthoredSourceResult =
| { ok: true; value: MechanicAuthoredSource }
| { ok: false; errors: MechanicAuthoredDescriptionError[] };
export type MechanicAuthoredFingerprintPayload = {
kind: 'mechanic_authored_source';
version: typeof MECHANIC_AUTHORED_CONTRACT_VERSION;
owner: MechanicAuthoredOwner;
template: string;
variants: Array<{
key: string;
materialized: string;
slots: MechanicAuthoredSlot[];
}>;
specialization?: SpecializationAuthoredInputs;
};
const TOKEN_PATTERN = /\{([^{}]+)\}/g;
const ALLOWED_NAMED_TOKENS = new Set(['duration', 'max_stacks']);
const objectValue = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const hasOnlyKeys = (
value: Record<string, unknown>,
allowed: readonly string[],
) => Object.keys(value).every((key) => allowed.includes(key));
function requiredVariantKeys(owner: MechanicAuthoredOwner): readonly string[] {
switch (owner.type) {
case 'talent':
return owner.scope === 'standard' || owner.hasTierII
? ['I', 'II']
: ['I'];
case 'os_protocol_talent':
case 'specialization_tree_rank':
return ['base'];
case 'skill_chip_talent':
return ['minimum', 'maximum'];
}
}
function tokenKeys(template: string) {
return [...template.matchAll(TOKEN_PATTERN)].map((match) => match[1]);
}
function isAllowedToken(key: string) {
return ALLOWED_NAMED_TOKENS.has(key) || /^[1-9]\d*$/.test(key);
}
function sourceErrors(source: MechanicAuthoredSource) {
const errors: MechanicAuthoredDescriptionError[] = [];
if (source.version !== MECHANIC_AUTHORED_CONTRACT_VERSION) {
errors.push({
code: 'unsupported_contract',
path: 'version',
message: `Authored-description contract version ${String(source.version)} is not supported.`,
});
}
if (!source.owner.id.trim()) {
errors.push({
code: 'invalid_owner',
path: 'owner.id',
message: 'The owner ID is required.',
});
}
if (!source.template.trim()) {
errors.push({
code: 'invalid_template',
path: 'template',
message: 'The authored template is required.',
});
}
const tokens = tokenKeys(source.template);
if (/[{}]/.test(source.template.replace(TOKEN_PATTERN, ''))) {
errors.push({
code: 'unknown_token',
path: 'template',
message: 'The authored template contains malformed placeholder braces.',
});
}
for (const token of tokens) {
if (!isAllowedToken(token)) {
errors.push({
code: 'unknown_token',
path: 'template',
message: `The authored template contains unsupported token {${token}}.`,
});
}
}
if (source.owner.type === 'specialization_tree_rank') {
if (
!source.specialization?.rankDescription.trim() ||
!source.specialization.effectId.trim() ||
!source.specialization.effectTemplate.trim()
) {
errors.push({
code: 'invalid_specialization_source',
path: 'specialization',
message:
'Specialization rank description, effect identity, and effect template are required.',
});
}
} else if (source.specialization !== undefined) {
errors.push({
code: 'invalid_specialization_source',
path: 'specialization',
message:
'Specialization inputs are valid only for a Specialization tree-rank owner.',
});
}
const expected = requiredVariantKeys(source.owner);
const actual = source.variants.map((variant) => variant.key);
if (
actual.length !== expected.length ||
actual.some((key, index) => key !== expected[index])
) {
errors.push({
code: 'invalid_variant',
path: 'variants',
message: `Expected authored variants in this order: ${expected.join(', ')}.`,
});
}
return { errors, tokens };
}
function materializeKnownMechanicAuthoredDescription(
source: MechanicAuthoredSource,
): MechanicAuthoredDescriptionResult {
const { errors, tokens } = sourceErrors(source);
const usedTokens = new Set(tokens.filter(isAllowedToken));
const variants: MaterializedMechanicVariant[] = [];
source.variants.forEach((variant, variantIndex) => {
const path = `variants[${variantIndex}]`;
const slots = new Map<string, MechanicAuthoredSlot>();
const slotOrders = new Set<number>();
for (const [slotIndex, slot] of variant.slots.entries()) {
if (
!isAllowedToken(slot.key) ||
!Number.isInteger(slot.order) ||
slot.order < 0 ||
!slot.value.trim() ||
/[{}]/.test(slot.value) ||
slots.has(slot.key) ||
slotOrders.has(slot.order)
) {
errors.push({
code: 'invalid_slot',
path: `${path}.slots[${slotIndex}]`,
message: `Authored slot ${slot.key || '(empty)'} is invalid or duplicated.`,
});
continue;
}
slots.set(slot.key, slot);
slotOrders.add(slot.order);
if (!usedTokens.has(slot.key)) {
errors.push({
code: 'unused_slot',
path: `${path}.slots[${slotIndex}]`,
message: `Authored slot ${slot.key} is not referenced by the template.`,
});
}
}
for (const token of usedTokens) {
if (!slots.has(token)) {
errors.push({
code: 'unresolved_token',
path: `${path}.slots`,
message: `Authored token {${token}} has no value for variant ${variant.key}.`,
});
}
}
const text = source.template.replace(TOKEN_PATTERN, (match, key: string) =>
isAllowedToken(key) ? (slots.get(key)?.value ?? match) : match,
);
if (/[{}]/.test(text)) {
errors.push({
code: 'unresolved_token',
path: `${path}.materialized`,
message: `Materialized variant ${variant.key} contains an unresolved placeholder.`,
});
}
if (variant.materialized !== text) {
errors.push({
code: 'materialization_mismatch',
path: `${path}.materialized`,
message: `Stored materialization for variant ${variant.key} does not match the authored source.`,
});
}
variants.push({ key: variant.key, text, slots: variant.slots });
});
return errors.length ? { ok: false, errors } : { ok: true, variants };
}
function parseMechanicAuthoredSourceShape(
value: unknown,
): ParsedMechanicAuthoredSourceResult {
const invalid = (
path: string,
message: string,
): ParsedMechanicAuthoredSourceResult => ({
ok: false,
errors: [{ code: 'invalid_source', path, message }],
});
if (!objectValue(value))
return invalid('', 'The authored source must be an object.');
if (
!hasOnlyKeys(value, [
'version',
'owner',
'template',
'variants',
'specialization',
])
)
return invalid('', 'The authored source contains an unknown field.');
if (value.version !== MECHANIC_AUTHORED_CONTRACT_VERSION)
return {
ok: false,
errors: [
{
code: 'unsupported_contract',
path: 'version',
message: 'The authored source has an unsupported contract version.',
},
],
};
if (!objectValue(value.owner) || typeof value.owner.id !== 'string')
return invalid('owner', 'The authored source owner is invalid.');
const ownerType = value.owner.type;
if (
typeof ownerType !== 'string' ||
![
'talent',
'os_protocol_talent',
'skill_chip_talent',
'specialization_tree_rank',
].includes(ownerType)
)
return invalid('owner.type', 'The authored source owner type is invalid.');
if (ownerType === 'talent') {
if (!hasOnlyKeys(value.owner, ['type', 'id', 'scope', 'hasTierII']))
return invalid('owner', 'The Talent owner contains an unknown field.');
if (value.owner.scope !== 'standard' && value.owner.scope !== 'exotic')
return invalid('owner.scope', 'The Talent scope is invalid.');
if (
value.owner.hasTierII !== undefined &&
typeof value.owner.hasTierII !== 'boolean'
)
return invalid(
'owner.hasTierII',
'Talent Tier II presence must be boolean.',
);
} else if (!hasOnlyKeys(value.owner, ['type', 'id'])) {
return invalid(
'owner',
'The authored source owner contains an unknown field.',
);
}
if (typeof value.template !== 'string' || !Array.isArray(value.variants))
return invalid(
'template',
'The authored template or variants are invalid.',
);
for (const [variantIndex, variant] of value.variants.entries()) {
if (
!objectValue(variant) ||
!hasOnlyKeys(variant, ['key', 'materialized', 'slots']) ||
typeof variant.key !== 'string' ||
typeof variant.materialized !== 'string' ||
!Array.isArray(variant.slots)
)
return invalid(
`variants[${variantIndex}]`,
'The authored variant is invalid.',
);
for (const [slotIndex, slot] of variant.slots.entries()) {
if (
!objectValue(slot) ||
!hasOnlyKeys(slot, ['key', 'value', 'order', 'unit', 'attributeId']) ||
typeof slot.key !== 'string' ||
typeof slot.value !== 'string' ||
typeof slot.order !== 'number' ||
(slot.unit !== undefined && typeof slot.unit !== 'string') ||
(slot.attributeId !== undefined && typeof slot.attributeId !== 'string')
)
return invalid(
`variants[${variantIndex}].slots[${slotIndex}]`,
'The authored slot is invalid.',
);
}
}
if (value.specialization !== undefined) {
if (
!objectValue(value.specialization) ||
!hasOnlyKeys(value.specialization, [
'rankDescription',
'effectId',
'effectTemplate',
]) ||
typeof value.specialization.rankDescription !== 'string' ||
typeof value.specialization.effectId !== 'string' ||
typeof value.specialization.effectTemplate !== 'string'
)
return invalid(
'specialization',
'The Specialization authored inputs are invalid.',
);
}
return { ok: true, value: value as unknown as MechanicAuthoredSource };
}
export function materializeMechanicAuthoredDescription(
value: unknown,
): MechanicAuthoredDescriptionResult {
const parsed = parseMechanicAuthoredSourceShape(value);
return parsed.ok
? materializeKnownMechanicAuthoredDescription(parsed.value)
: parsed;
}
export function parseMechanicAuthoredSource(
value: unknown,
): ParsedMechanicAuthoredSourceResult {
const parsed = parseMechanicAuthoredSourceShape(value);
if (!parsed.ok) return parsed;
const materialized = materializeKnownMechanicAuthoredDescription(
parsed.value,
);
return materialized.ok ? parsed : materialized;
}
export function mechanicAuthoredFingerprintPayload(
source: MechanicAuthoredSource,
): MechanicAuthoredFingerprintPayload | null {
const materialized = materializeMechanicAuthoredDescription(source);
if (!materialized.ok) return null;
return {
kind: 'mechanic_authored_source',
version: MECHANIC_AUTHORED_CONTRACT_VERSION,
owner:
source.owner.type === 'talent'
? {
type: source.owner.type,
id: source.owner.id,
scope: source.owner.scope,
...(source.owner.hasTierII ? { hasTierII: true } : {}),
}
: { type: source.owner.type, id: source.owner.id },
template: source.template,
variants: materialized.variants.map((variant) => ({
key: variant.key,
materialized: variant.text,
slots: [...variant.slots].sort((left, right) => left.order - right.order),
})),
...(source.specialization ? { specialization: source.specialization } : {}),
};
}
@@ -0,0 +1,458 @@
import { describe, expect, it } from 'vitest';
import {
canonicalMechanicProgramJson,
classifyMechanicLifecycle,
hashMechanicAuthoredSource,
hashMechanicProgram,
mechanicProgramSemanticPayload,
type MechanicLifecycleInput,
type MechanicModelRevision,
type MechanicProgramHashSource,
} from './mechanic-lifecycle.js';
import type { MechanicProgram } from './mechanic.js';
const program = (): MechanicProgram => ({
id: 'generated-program-id',
name: 'Sustained fire',
scenarioInputs: [
{
key: 'distance',
label: 'Distance',
kind: 'number',
readiness: 'requires_assumption',
description: 'Distance to target',
defaultValue: 10,
bestCaseValue: 5,
min: 0,
max: 100,
},
{
key: 'headshot',
label: 'Headshots',
kind: 'toggle',
defaultValue: false,
bestCaseValue: true,
},
],
pools: [
{
key: 'stacks',
label: 'Stacks',
scope: 'agent',
initial: 0,
min: 0,
max: 3,
bestCaseValue: 3,
},
{
key: 'cooldown',
label: 'Cooldown',
scope: 'agent',
initial: 0,
min: 0,
expiresAfterSeconds: 10,
},
],
clauses: [
{
id: 'generated-clause-id',
label: 'On weapon hit',
when: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [
{
kind: 'add',
pool: 'stacks',
value: { ref: { source: 'scenario', key: 'headshot' } },
},
],
outcomes: [
{
id: 'generated-outcome-id',
label: 'Weapon damage',
phase: 'on_event',
kind: 'attribute_modifier',
scope: 'equipped_weapon',
value: 5,
basis: 'flat',
attributeId: 'weaponDamage',
perStack: true,
},
],
},
],
});
const hashSource = (): MechanicProgramHashSource => ({
...program(),
simulationReadiness: 'captured_only',
sourceVariants: ['I', 'II'],
});
const revision = (
overrides: Partial<MechanicModelRevision> = {},
): MechanicModelRevision => ({
program: program(),
sourceFingerprint: 'a'.repeat(64),
programHash: '0'.repeat(64),
mechanicContractVersion: 1,
simulationReadiness: 'ready',
...overrides,
});
const lifecycleInput = (
selectedRevision: MechanicModelRevision | null,
publiclyRenderable = true,
) => ({
selectedRevision,
currentSourceFingerprint: 'a'.repeat(64),
...(publiclyRenderable
? { resolveAttributeLabel: (attributeId: string) => attributeId }
: {}),
});
describe('Mechanic Program lifecycle and semantic hashing', () => {
it('builds explicit canonical JSON and excludes generated IDs and provenance', () => {
const source = hashSource() as MechanicProgramHashSource &
Record<string, unknown>;
source.persistenceRowId = 'row-1';
source.updatedAt = '2026-08-27T00:00:00Z';
source.editorState = { activeClause: 'generated-clause-id' };
source.recipeProvenance = 'recipe-a';
const json = canonicalMechanicProgramJson(source);
expect(json).toContain('"kind":"mechanic_program"');
expect(json).toContain('"version":1');
expect(json).not.toContain('generated-program-id');
expect(json).not.toContain('generated-clause-id');
expect(json).not.toContain('generated-outcome-id');
expect(json).not.toContain('persistenceRowId');
expect(json).not.toContain('recipeProvenance');
});
it('does not change when keyed Scenario Inputs or pools are reordered', async () => {
const original = hashSource();
const reordered: MechanicProgramHashSource = {
...original,
scenarioInputs: [...(original.scenarioInputs ?? [])].reverse(),
pools: [...(original.pools ?? [])].reverse(),
};
await expect(hashMechanicProgram(original)).resolves.toBe(
await hashMechanicProgram(reordered),
);
});
it('changes when clause, mutation, or outcome execution/render order changes', async () => {
const original = program();
const reorderedClause: MechanicProgram = {
...original,
clauses: [
...original.clauses,
{
id: 'second-clause',
label: 'Later clause',
when: 'tick',
reducer: { kind: 'none' },
mutations: [],
},
],
};
const reorderedMutation: MechanicProgram = {
...original,
clauses: [
{
...original.clauses[0],
mutations: [{ kind: 'none' }, ...original.clauses[0].mutations],
},
],
};
const reorderedOutcome: MechanicProgram = {
...original,
clauses: [
{
...original.clauses[0],
outcomes: [
...(original.clauses[0].outcomes ?? []),
{
...(original.clauses[0].outcomes?.[0] ?? {
id: 'second-outcome',
label: 'Second',
phase: 'on_event',
kind: 'marker',
scope: 'self',
value: 0,
basis: 'flat',
}),
id: 'second-outcome',
label: 'Second',
},
],
},
],
};
const originalHash = await hashMechanicProgram(original);
await expect(hashMechanicProgram(reorderedClause)).resolves.not.toBe(
originalHash,
);
await expect(hashMechanicProgram(reorderedMutation)).resolves.not.toBe(
originalHash,
);
await expect(hashMechanicProgram(reorderedOutcome)).resolves.not.toBe(
originalHash,
);
});
it('rejects invalid pool scopes and reducer kinds from canonical hashes', async () => {
const invalidScope = program();
(invalidScope.pools![0] as { scope: string }).scope = 'squad';
await expect(hashMechanicProgram(invalidScope)).rejects.toThrow(
'unsupported scope',
);
const invalidReducer = program();
(invalidReducer.clauses[0] as { reducer: { kind: string } }).reducer = {
kind: 'unknown',
};
await expect(hashMechanicProgram(invalidReducer)).rejects.toThrow(
'unsupported kind',
);
});
it('keeps generated identities out of the semantic hash', async () => {
const changedIds: MechanicProgram = {
...program(),
id: 'another-program-id',
clauses: [
{
...program().clauses[0],
id: 'another-clause-id',
outcomes: [
{
...(program().clauses[0].outcomes?.[0] as NonNullable<
MechanicProgram['clauses'][number]['outcomes']
>[number]),
id: 'another-outcome-id',
},
],
},
],
};
await expect(hashMechanicProgram(program())).resolves.toBe(
await hashMechanicProgram(changedIds),
);
});
it('classifies selected models with fail-closed lifecycle boundaries', async () => {
await expect(
classifyMechanicLifecycle(lifecycleInput(null)),
).resolves.toEqual({
status: 'absent',
reason: 'no_selected_revision',
});
await expect(
classifyMechanicLifecycle(
lifecycleInput(
revision({
program: { ...program(), clauses: [] },
simulationReadiness: 'captured_only',
}),
),
),
).resolves.toEqual({ status: 'absent', reason: 'empty_captured_program' });
await expect(
classifyMechanicLifecycle(
lifecycleInput(revision({ program: { ...program(), name: '' } })),
),
).resolves.toMatchObject({ status: 'absent', reason: 'invalid_program' });
await expect(
classifyMechanicLifecycle({
selectedRevision: revision({ sourceFingerprint: 'b'.repeat(64) }),
currentSourceFingerprint: 'c'.repeat(64),
resolveAttributeLabel: (attributeId) => attributeId,
}),
).resolves.toEqual({ status: 'stale', reason: 'fingerprint_mismatch' });
await expect(
classifyMechanicLifecycle(
lifecycleInput(revision({ mechanicContractVersion: 999 })),
),
).resolves.toEqual({ status: 'stale', reason: 'unsupported_contract' });
await expect(
classifyMechanicLifecycle(lifecycleInput(revision(), false)),
).resolves.toMatchObject({
status: 'absent',
reason: 'unrenderable_program',
});
await expect(
classifyMechanicLifecycle({
selectedRevision: revision(),
currentSourceFingerprint: 'a'.repeat(64),
} as MechanicLifecycleInput),
).resolves.toMatchObject({
status: 'absent',
reason: 'unrenderable_program',
});
});
it('rejects non-SHA fingerprints and publicly unrenderable program fields', async () => {
await expect(
classifyMechanicLifecycle({
selectedRevision: revision({ sourceFingerprint: 'same' }),
currentSourceFingerprint: 'same',
resolveAttributeLabel: (attributeId) => attributeId,
}),
).resolves.toMatchObject({ status: 'absent', reason: 'invalid_program' });
const invalidPhase = program();
(invalidPhase.clauses[0].outcomes![0] as { phase: string }).phase =
'sometimes';
const selectedRevision = revision({ program: invalidPhase });
selectedRevision.programHash = await hashMechanicProgram({
...invalidPhase,
simulationReadiness: selectedRevision.simulationReadiness,
});
await expect(
classifyMechanicLifecycle(lifecycleInput(selectedRevision)),
).resolves.toMatchObject({
status: 'absent',
reason: 'unrenderable_program',
});
});
it.each(['ready', 'requires_assumptions', 'captured_only'] as const)(
'classifies a matching non-empty %s model as current',
async (simulationReadiness) => {
const selectedRevision = revision({ simulationReadiness });
selectedRevision.programHash = await hashMechanicProgram({
...selectedRevision.program!,
simulationReadiness,
});
await expect(
classifyMechanicLifecycle(lifecycleInput(selectedRevision)),
).resolves.toEqual({
status: 'current',
fingerprint: 'a'.repeat(64),
programHash: selectedRevision.programHash,
simulationReadiness,
});
},
);
it('fails closed when the selected program hash is missing or does not match', async () => {
await expect(
classifyMechanicLifecycle(
lifecycleInput({
...revision(),
programHash: undefined,
} as unknown as MechanicModelRevision),
),
).resolves.toEqual({ status: 'absent', reason: 'invalid_program_hash' });
await expect(
classifyMechanicLifecycle(
lifecycleInput(revision({ programHash: '0'.repeat(64) })),
),
).resolves.toEqual({ status: 'absent', reason: 'invalid_program_hash' });
});
it('distinguishes an explicit zero weight from an omitted weight', async () => {
const implicit = program();
const zeroWeight: MechanicProgram = {
...implicit,
clauses: [{ ...implicit.clauses[0], weight: 0 }],
};
await expect(hashMechanicProgram(zeroWeight)).resolves.not.toBe(
await hashMechanicProgram(implicit),
);
});
it('normalizes omitted runtime defaults to their explicit equivalents', async () => {
const implicit = program();
const explicit: MechanicProgram = {
...implicit,
scenarioInputs: implicit.scenarioInputs?.map((input) => ({
...input,
readiness: input.readiness ?? 'ready',
})),
clauses: implicit.clauses.map((clause) => ({
...clause,
mutations: clause.mutations.map((mutation) =>
mutation.kind === 'transfer'
? {
...mutation,
value: mutation.value ?? 'all',
clearSource: mutation.clearSource ?? true,
}
: mutation,
),
outcomes: clause.outcomes?.map((outcome) => ({
...outcome,
delivery: outcome.delivery ?? 'instant',
combatTiming: outcome.combatTiming ?? 'current',
perStack: outcome.perStack ?? false,
})),
})),
};
await expect(hashMechanicProgram(implicit)).resolves.toBe(
await hashMechanicProgram(explicit),
);
});
it('hashes a valid authored source and rejects unresolved source values', async () => {
const source = {
version: 1 as const,
owner: { type: 'os_protocol_talent' as const, id: 'protocol-1' },
template: 'Gain {1}.',
variants: [
{
key: 'base',
materialized: 'Gain 10%.',
slots: [{ key: '1', value: '10%', order: 0 }],
},
],
};
await expect(hashMechanicAuthoredSource(source)).resolves.toMatchObject({
ok: true,
fingerprint: expect.stringMatching(/^[0-9a-f]{64}$/),
payload: { kind: 'mechanic_authored_source', version: 1 },
});
source.variants[0].slots = [];
await expect(hashMechanicAuthoredSource(source)).resolves.toMatchObject({
ok: false,
errors: expect.arrayContaining([
expect.objectContaining({ code: 'unresolved_token' }),
]),
});
});
it('returns a stable fixed SHA-256 vector as lowercase hexadecimal', async () => {
const hash = await hashMechanicProgram({
id: 'generated-id',
name: 'x',
clauses: [
{
id: 'generated-clause-id',
when: 'tick',
reducer: { kind: 'none' },
mutations: [],
},
],
});
expect(hash).toBe(
'4ba3a6b9ddc7831375779c865928a715bc3dbd2b8aac77b889fc7fcc672d8339',
);
expect(hash).toMatch(/^[0-9a-f]{64}$/);
});
it('exposes canonical payload fields in stable key order', () => {
const payload = mechanicProgramSemanticPayload(hashSource());
expect(Object.keys(payload)).toEqual([
'kind',
'version',
'name',
'scenarioInputs',
'pools',
'clauses',
'simulationReadiness',
'sourceVariants',
]);
});
});
+694
View File
@@ -0,0 +1,694 @@
import {
mechanicAuthoredFingerprintPayload,
materializeMechanicAuthoredDescription,
type MechanicAuthoredDescriptionError,
type MechanicAuthoredFingerprintPayload,
type MechanicAuthoredSource,
} from './mechanic-authored-description.js';
import {
renderPublicMechanicPrograms,
type MechanicAttributeLabelResolver,
} from './mechanic-public-renderer.js';
import {
validateMechanicProgram,
type ClauseMutation,
type EntitySelector,
type MechanicClause,
type MechanicOutcome,
type MechanicOutcomeTargeting,
type MechanicProgram,
type NumericExpression,
type PoolDefinition,
type Predicate,
type Reducer,
type ScenarioInputDefinition,
type Scalar,
type ValueReference,
} from './mechanic.js';
/** The version of the canonical Mechanic Program hash contract. */
export const MECHANIC_PROGRAM_HASH_VERSION = 1 as const;
export const MECHANIC_PROGRAM_HASH_KIND = 'mechanic_program' as const;
export type MechanicLifecycle = 'absent' | 'stale' | 'current';
export type MechanicAuthoredFingerprintResult =
| {
ok: true;
fingerprint: string;
payload: MechanicAuthoredFingerprintPayload;
}
| { ok: false; errors: MechanicAuthoredDescriptionError[] };
export type MechanicSimulationReadiness =
'ready' | 'requires_assumptions' | 'captured_only';
export type CanonicalJsonValue =
| null
| boolean
| number
| string
| CanonicalJsonValue[]
| { [key: string]: CanonicalJsonValue };
export type CanonicalMechanicProgram = {
kind: typeof MECHANIC_PROGRAM_HASH_KIND;
version: typeof MECHANIC_PROGRAM_HASH_VERSION;
name: string;
simulationReadiness?: MechanicSimulationReadiness;
sourceVariants?: CanonicalJsonValue;
scenarioInputs: Array<{ [key: string]: CanonicalJsonValue }>;
pools: Array<{ [key: string]: CanonicalJsonValue }>;
clauses: Array<{ [key: string]: CanonicalJsonValue }>;
};
/**
* A program plus source metadata which belongs to the model contract rather
* than to the executable graph. Metadata is optional because the current
* runtime MechanicProgram type predates model revisions.
*/
export type MechanicProgramHashSource = MechanicProgram & {
simulationReadiness?: MechanicSimulationReadiness;
sourceVariants?: CanonicalJsonValue;
};
export type MechanicProgramHashInput = MechanicProgramHashSource;
export type MechanicModelRevision = {
program: MechanicProgram | null;
sourceFingerprint: string;
programHash: string;
sourceVariants?: CanonicalJsonValue;
mechanicContractVersion: number;
simulationReadiness: MechanicSimulationReadiness;
};
export type MechanicLifecycleInput = {
selectedRevision: MechanicModelRevision | null;
currentSourceFingerprint: string;
resolveAttributeLabel?: MechanicAttributeLabelResolver;
supportedMechanicContractVersion?: number;
};
export type MechanicLifecycleReason =
| 'no_selected_revision'
| 'empty_program'
| 'empty_captured_program'
| 'invalid_program'
| 'unrenderable_program'
| 'fingerprint_mismatch'
| 'unsupported_contract'
| 'invalid_program_hash'
| 'hash_unavailable';
export type MechanicLifecycleResult =
| {
status: 'absent';
reason:
| 'no_selected_revision'
| 'empty_program'
| 'empty_captured_program'
| 'invalid_program'
| 'unrenderable_program'
| 'invalid_program_hash'
| 'hash_unavailable';
errors?: string[];
}
| {
status: 'stale';
reason: 'fingerprint_mismatch' | 'unsupported_contract';
}
| {
status: 'current';
fingerprint: string;
programHash: string;
simulationReadiness: MechanicSimulationReadiness;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function canonicalizeValue(value: unknown): CanonicalJsonValue {
if (value === null) return null;
if (typeof value === 'string' || typeof value === 'boolean') return value;
if (typeof value === 'number') {
if (!Number.isFinite(value)) {
throw new TypeError(
'Mechanic Program hash values must be finite numbers.',
);
}
return value;
}
if (Array.isArray(value)) return value.map(canonicalizeValue);
if (isRecord(value)) {
const result: { [key: string]: CanonicalJsonValue } = {};
for (const key of Object.keys(value).sort()) {
const child = value[key];
if (child !== undefined) result[key] = canonicalizeValue(child);
}
return result;
}
throw new TypeError('Mechanic Program hash values must be JSON values.');
}
function canonicalObject(fields: Record<string, unknown>): {
[key: string]: CanonicalJsonValue;
} {
return canonicalizeValue(fields) as { [key: string]: CanonicalJsonValue };
}
function stableJson(value: CanonicalJsonValue): string {
return JSON.stringify(value);
}
function stableSemanticSort<T extends CanonicalJsonValue>(values: T[]): T[] {
return values
.map((value, index) => ({ value, index, key: stableJson(value) }))
.sort((left, right) => {
if (left.key < right.key) return -1;
if (left.key > right.key) return 1;
return left.index - right.index;
})
.map(({ value }) => value);
}
function canonicalScenarioInput(input: ScenarioInputDefinition): {
[key: string]: CanonicalJsonValue;
} {
return canonicalObject({
key: input.key,
label: input.label,
kind: input.kind,
readiness: input.readiness ?? 'ready',
description: input.description,
defaultValue: input.defaultValue,
bestCaseValue: input.bestCaseValue,
min: input.min,
max: input.max,
});
}
function canonicalPool(pool: PoolDefinition): {
[key: string]: CanonicalJsonValue;
} {
if (pool.scope !== 'agent' && pool.scope !== 'target') {
throw new TypeError('Mechanic pools have an unsupported scope.');
}
return canonicalObject({
key: pool.key,
label: pool.label,
scope: pool.scope,
initial: pool.initial,
min: pool.min,
max: pool.max,
bestCaseValue: pool.bestCaseValue,
expiresAfterSeconds: pool.expiresAfterSeconds,
});
}
function canonicalEntitySelector(selector: EntitySelector): {
[key: string]: CanonicalJsonValue;
} {
return canonicalObject({
kind: selector.kind,
path: 'path' in selector ? selector.path : undefined,
});
}
function canonicalReference(reference: ValueReference): {
[key: string]: CanonicalJsonValue;
} {
return canonicalObject({
source: reference.source,
path: 'path' in reference ? reference.path : undefined,
timing: 'timing' in reference ? reference.timing : undefined,
key: 'key' in reference ? reference.key : undefined,
attributeId: 'attributeId' in reference ? reference.attributeId : undefined,
pool: 'pool' in reference ? reference.pool : undefined,
entity:
'entity' in reference && reference.entity
? canonicalEntitySelector(reference.entity)
: undefined,
});
}
function canonicalExpression(
expression: NumericExpression,
): CanonicalJsonValue {
if (typeof expression === 'number') return canonicalizeValue(expression);
if (!isRecord(expression)) {
throw new TypeError('Numeric expressions must be typed JSON values.');
}
if ('ref' in expression) {
if (!isRecord(expression.ref))
throw new TypeError('Numeric expression references must be objects.');
return canonicalObject({
ref: canonicalReference(expression.ref as ValueReference),
});
}
const op = expression.op;
switch (op) {
case 'floor':
return canonicalObject({
op,
value: canonicalExpression(expression.value as NumericExpression),
});
case 'clamp':
return canonicalObject({
op,
value: canonicalExpression(expression.value as NumericExpression),
min: canonicalExpression(expression.min as NumericExpression),
max: canonicalExpression(expression.max as NumericExpression),
});
case 'add':
case 'subtract':
case 'multiply':
case 'divide':
case 'min':
case 'max':
return canonicalObject({
op,
left: canonicalExpression(expression.left as NumericExpression),
right: canonicalExpression(expression.right as NumericExpression),
});
default:
throw new TypeError('Numeric expressions have an unsupported operator.');
}
}
function canonicalOperand(value: ValueReference | Scalar): CanonicalJsonValue {
return isRecord(value) && 'source' in value
? canonicalReference(value as ValueReference)
: canonicalizeValue(value);
}
function canonicalPredicate(predicate: Predicate): CanonicalJsonValue {
if (!isRecord(predicate)) {
throw new TypeError('Predicates must be typed JSON values.');
}
switch (predicate.kind) {
case 'true':
return { kind: 'true' };
case 'exists':
return canonicalObject({
kind: 'exists',
value: canonicalReference(predicate.value as ValueReference),
});
case 'compare':
return canonicalObject({
kind: 'compare',
left: canonicalOperand(predicate.left as ValueReference | Scalar),
operator: predicate.operator,
right: canonicalOperand(predicate.right as ValueReference | Scalar),
});
case 'all':
case 'any':
return canonicalObject({
kind: predicate.kind,
// Logical member order is retained because deterministic rendering
// presents the members in this order.
predicates: (predicate.predicates as Predicate[]).map(
canonicalPredicate,
),
});
case 'not':
return canonicalObject({
kind: 'not',
predicate: canonicalPredicate(predicate.predicate as Predicate),
});
default:
throw new TypeError('Predicates have an unsupported kind.');
}
}
function canonicalReducer(reducer: Reducer): CanonicalJsonValue {
if (!isRecord(reducer)) {
throw new TypeError('Reducers must be typed JSON values.');
}
const groupBy =
'groupBy' in reducer && reducer.groupBy
? canonicalReference(reducer.groupBy as ValueReference)
: undefined;
switch (reducer.kind) {
case 'none':
return { kind: 'none' };
case 'count':
return canonicalObject({
kind: reducer.kind,
threshold: reducer.threshold,
reset: reducer.reset ?? 'zero',
groupBy,
});
case 'sliding_count':
return canonicalObject({
kind: reducer.kind,
threshold: reducer.threshold,
reset: reducer.reset ?? 'zero',
windowSeconds: reducer.windowSeconds,
groupBy,
});
case 'modulo':
return canonicalObject({ kind: 'modulo', every: reducer.every, groupBy });
case 'consecutive_count':
return canonicalObject({
kind: 'consecutive_count',
threshold: reducer.threshold,
groupBy,
});
case 'distinct_count':
return canonicalObject({
kind: 'distinct_count',
threshold: reducer.threshold,
windowSeconds: reducer.windowSeconds,
distinctBy: canonicalReference(reducer.distinctBy),
});
case 'duration':
return canonicalObject({
kind: 'duration',
seconds: reducer.seconds,
repeat: reducer.repeat ?? false,
groupBy,
});
case 'periodic':
return canonicalObject({
kind: 'periodic',
intervalSeconds: canonicalExpression(reducer.intervalSeconds),
immediate: reducer.immediate ?? false,
groupBy,
});
case 'derived_scalar':
return canonicalObject({
kind: 'derived_scalar',
value: canonicalExpression(reducer.value),
});
default:
throw new TypeError('Reducers have an unsupported kind.');
}
}
function canonicalMutation(mutation: ClauseMutation): CanonicalJsonValue {
if (!isRecord(mutation)) {
throw new TypeError('Mutations must be typed JSON values.');
}
const entity =
'entity' in mutation && mutation.entity
? canonicalEntitySelector(mutation.entity as EntitySelector)
: undefined;
switch (mutation.kind) {
case 'none':
return { kind: 'none' };
case 'add':
case 'set':
case 'consume':
case 'derive':
return canonicalObject({
kind: mutation.kind,
pool: mutation.pool,
entity,
value: canonicalExpression(mutation.value),
});
case 'clear':
return canonicalObject({ kind: 'clear', pool: mutation.pool, entity });
case 'transfer':
return canonicalObject({
kind: 'transfer',
pool: mutation.pool,
from: canonicalEntitySelector(mutation.from),
to: canonicalEntitySelector(mutation.to),
value:
mutation.value === undefined
? 'all'
: mutation.value === 'all'
? 'all'
: canonicalExpression(mutation.value),
clearSource: mutation.clearSource ?? true,
});
case 'extend_duration':
return canonicalObject({
kind: 'extend_duration',
target: mutation.target,
seconds: canonicalExpression(mutation.seconds),
});
case 'start_cooldown_on_max':
return canonicalObject({
kind: 'start_cooldown_on_max',
stackPool: mutation.stackPool,
cooldownPool: mutation.cooldownPool,
});
default:
throw new TypeError('Mutations have an unsupported kind.');
}
}
function canonicalTargeting(
targeting: MechanicOutcomeTargeting,
): CanonicalJsonValue {
return canonicalObject({
kind: targeting.kind,
population: 'population' in targeting ? targeting.population : undefined,
center: 'center' in targeting ? targeting.center : undefined,
validTargets:
'validTargets' in targeting ? targeting.validTargets : undefined,
radiusMeters: targeting.radiusMeters,
});
}
function canonicalOutcome(outcome: MechanicOutcome): {
[key: string]: CanonicalJsonValue;
} {
// Outcome IDs are generated identities. Every other field is either typed
// behavior or text consumed by deterministic rendering.
return canonicalObject({
label: outcome.label,
phase: outcome.phase,
kind: outcome.kind,
scope: outcome.scope,
value: canonicalExpression(outcome.value),
basis: outcome.basis,
resource: outcome.resource,
delivery: outcome.delivery ?? 'instant',
durationSeconds: outcome.durationSeconds,
combatTiming: outcome.combatTiming ?? 'current',
attributeId: outcome.attributeId,
statePool: outcome.statePool,
perStack: outcome.perStack ?? false,
minStacks: outcome.minStacks,
maxStacks: outcome.maxStacks,
targeting: outcome.targeting
? canonicalTargeting(outcome.targeting)
: undefined,
probabilityPercent: outcome.probabilityPercent,
});
}
function canonicalClause(clause: MechanicClause): {
[key: string]: CanonicalJsonValue;
} {
// Clause IDs and recipe keys are generated/persistence provenance. The
// remaining fields preserve execution and deterministic rendering order.
return canonicalObject({
label: clause.label,
when: clause.when,
if: clause.if ? canonicalPredicate(clause.if) : undefined,
reducer: canonicalReducer(clause.reducer),
cooldownSeconds: clause.cooldownSeconds,
weight:
clause.weight !== undefined
? canonicalExpression(clause.weight)
: undefined,
procChancePercent: clause.procChancePercent,
requiresAssignedAbilityActive: clause.requiresAssignedAbilityActive,
mutations: clause.mutations.map(canonicalMutation),
outcomes: (clause.outcomes ?? []).map(canonicalOutcome),
});
}
/**
* Builds the versioned semantic payload used by the Mechanic Program hash.
*
* Keyed Scenario Inputs and pools are sets and therefore sorted by their
* canonical semantics. Clauses, mutations, predicate members, and outcomes
* stay in source order because order affects execution or rendered text.
*/
export function mechanicProgramSemanticPayload(
input: MechanicProgramHashInput,
): CanonicalMechanicProgram {
const scenarioInputs = stableSemanticSort(
(input.scenarioInputs ?? []).map(canonicalScenarioInput),
);
const pools = stableSemanticSort((input.pools ?? []).map(canonicalPool));
const payload: CanonicalMechanicProgram = {
kind: MECHANIC_PROGRAM_HASH_KIND,
version: MECHANIC_PROGRAM_HASH_VERSION,
name: input.name,
scenarioInputs,
pools,
clauses: input.clauses.map(canonicalClause),
};
if (input.simulationReadiness !== undefined) {
payload.simulationReadiness = input.simulationReadiness;
}
if (input.sourceVariants !== undefined)
payload.sourceVariants = canonicalizeValue(input.sourceVariants);
return payload;
}
/** Returns canonical JSON bytes as a string, without a hidden domain separator. */
export function canonicalMechanicProgramJson(
input: MechanicProgramHashInput,
): string {
return stableJson(canonicalizeValue(mechanicProgramSemanticPayload(input)));
}
async function sha256Utf8(value: string): Promise<string> {
const crypto = globalThis.crypto;
if (!crypto?.subtle) {
throw new Error('Web Crypto SHA-256 is unavailable.');
}
const bytes = new TextEncoder().encode(value);
const digest = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, '0'),
).join('');
}
/** Computes the source fingerprint only after the authored contract validates. */
export async function hashMechanicAuthoredSource(
source: MechanicAuthoredSource,
): Promise<MechanicAuthoredFingerprintResult> {
const validation = materializeMechanicAuthoredDescription(source);
if (!validation.ok) return validation;
const payload = mechanicAuthoredFingerprintPayload(source);
if (!payload) {
return {
ok: false,
errors: [
{
code: 'invalid_source',
path: '',
message: 'The validated authored source could not be serialized.',
},
],
};
}
return {
ok: true,
fingerprint: await sha256Utf8(stableJson(canonicalizeValue(payload))),
payload,
};
}
/** Computes lowercase SHA-256 over UTF-8 canonical Mechanic Program JSON. */
export async function hashMechanicProgram(
input: MechanicProgramHashInput,
): Promise<string> {
return sha256Utf8(canonicalMechanicProgramJson(input));
}
const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
function lifecycleAbsent(
reason: Extract<MechanicLifecycleResult, { status: 'absent' }>['reason'],
errors?: string[],
): MechanicLifecycleResult {
return errors?.length
? { status: 'absent', reason, errors }
: { status: 'absent', reason };
}
/**
* Classifies one selected model without ever treating readiness as currency.
* Validation and renderability are checked before currency, so malformed
* records fail closed as absent rather than leaking a stale or current model.
*/
export async function classifyMechanicLifecycle(
input: MechanicLifecycleInput,
): Promise<MechanicLifecycleResult> {
const revision = input.selectedRevision;
if (!revision || !revision.program) {
return lifecycleAbsent('no_selected_revision');
}
const program = revision.program;
let validation;
try {
validation = validateMechanicProgram(program);
} catch {
return lifecycleAbsent('invalid_program', [
'The selected mechanic could not be validated safely.',
]);
}
if (!validation.ok)
return lifecycleAbsent('invalid_program', validation.errors);
if (program.clauses.length === 0) {
return lifecycleAbsent(
revision.simulationReadiness === 'captured_only'
? 'empty_captured_program'
: 'empty_program',
);
}
const rendering = renderPublicMechanicPrograms(
[{ key: 'base', label: 'Base', program }],
{ resolveAttributeLabel: input.resolveAttributeLabel },
);
if (!rendering.ok) {
return lifecycleAbsent(
'unrenderable_program',
rendering.errors.map((error) => error.message),
);
}
if (
!['ready', 'requires_assumptions', 'captured_only'].includes(
revision.simulationReadiness,
)
) {
return { status: 'stale', reason: 'unsupported_contract' };
}
const supportedContractVersion =
input.supportedMechanicContractVersion ?? MECHANIC_PROGRAM_HASH_VERSION;
if (
revision.mechanicContractVersion !== supportedContractVersion ||
!Number.isInteger(revision.mechanicContractVersion) ||
revision.mechanicContractVersion <= 0
) {
return { status: 'stale', reason: 'unsupported_contract' };
}
if (
!SHA256_HEX_PATTERN.test(revision.sourceFingerprint) ||
!SHA256_HEX_PATTERN.test(input.currentSourceFingerprint)
) {
return lifecycleAbsent('invalid_program', [
'The selected revision and authored source require lowercase SHA-256 fingerprints.',
]);
}
if (revision.sourceFingerprint !== input.currentSourceFingerprint) {
return { status: 'stale', reason: 'fingerprint_mismatch' };
}
if (!SHA256_HEX_PATTERN.test(revision.programHash))
return lifecycleAbsent('invalid_program_hash');
let programHash: string;
try {
programHash = await hashMechanicProgram({
...program,
simulationReadiness: revision.simulationReadiness,
...(revision.sourceVariants !== undefined
? { sourceVariants: revision.sourceVariants }
: {}),
});
} catch {
return lifecycleAbsent('hash_unavailable');
}
if (programHash !== revision.programHash) {
return lifecycleAbsent('invalid_program_hash');
}
return {
status: 'current',
fingerprint: input.currentSourceFingerprint,
programHash,
simulationReadiness: revision.simulationReadiness,
};
}
@@ -0,0 +1,714 @@
import { describe, expect, it } from 'vitest';
import {
renderPublicMechanicPrograms,
type MechanicPublicProgramVariant,
} from './mechanic-public-renderer.js';
import { MECHANIC_EVENT_KINDS } from './mechanic.js';
import type { MechanicProgram } from './mechanic.js';
const baseProgram = (
overrides: Partial<MechanicProgram> = {},
): MechanicProgram => ({
id: 'public-renderer-test',
name: 'Public renderer test',
clauses: [
{
id: 'hit',
label: 'Hit target',
when: 'weapon_hit',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.headshot' },
operator: 'eq',
right: true,
},
reducer: { kind: 'none' },
mutations: [{ kind: 'none' }],
outcomes: [
{
id: 'damage',
label: 'Headshot damage',
phase: 'on_event',
kind: 'direct_damage',
scope: 'target',
value: 12.5,
basis: 'flat',
},
],
},
],
...overrides,
});
const variants = (
...programs: Array<[string, MechanicProgram]>
): MechanicPublicProgramVariant[] =>
programs.map(([key, program]) => ({ key, program }));
const render = (
program: MechanicProgram,
options?: {
resolveAttributeLabel?: (attributeId: string) => string | null | undefined;
},
) => renderPublicMechanicPrograms([{ key: 'base', program }], options);
describe('public mechanic renderer', () => {
it('renders deterministic, readable prose for ordered variants', () => {
const input = variants(
['I', baseProgram()],
['II', baseProgram({ id: 'public-renderer-test-ii' })],
);
expect(renderPublicMechanicPrograms(input)).toEqual({
ok: true,
variants: [
{
key: 'I',
label: 'Tier I',
text: 'Tier I\n\nHit target: When Weapon hit occurs, provided that the event’s Headshot is equal to true. Headshot damage: when this event occurs, deal 12.5 damage to the target immediately.',
},
{
key: 'II',
label: 'Tier II',
text: 'Tier II\n\nHit target: When Weapon hit occurs, provided that the event’s Headshot is equal to true. Headshot damage: when this event occurs, deal 12.5 damage to the target immediately.',
},
],
});
});
it('preserves the supplied order of variants', () => {
const result = renderPublicMechanicPrograms(
variants(
['II', baseProgram({ id: 'tier-ii' })],
['I', baseProgram({ id: 'tier-i' })],
),
);
expect(result.ok && result.variants.map((variant) => variant.key)).toEqual([
'II',
'I',
]);
});
it('renders every event registry label without exposing the script format', () => {
const program = baseProgram({
clauses: MECHANIC_EVENT_KINDS.map((when) => ({
id: when,
when,
label: when,
reducer: { kind: 'none' as const },
mutations: [{ kind: 'none' as const }],
})),
});
const result = render(program);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.variants[0].text).not.toContain('WHEN ');
expect(result.variants[0].text).not.toContain('EVENT.');
expect(result.variants[0].text).not.toContain('{');
for (const clause of program.clauses) {
expect(result.variants[0].text).toContain(
(
{
shot_fired: 'Shot fired',
weapon_hit: 'Weapon hit',
skill_deployed: 'Skill deployed',
signature_ability_deployed: 'Signature Ability deployed',
skill_hit: 'Skill hit',
skill_multishot_triggered: 'Skill Multi-Shot triggered',
agent_hit: 'Agent hit',
health_damage_taken: 'Health damage taken',
damage_dealt: 'Damage dealt',
enemy_killed: 'Enemy killed',
assist: 'Assist',
ally_downed: 'Ally downed',
agent_downed: 'Agent downed',
ally_rescued: 'Ally rescued',
grenade_thrown: 'Grenade thrown',
medical_kit_used: 'Medical Kit used',
healing_applied: 'Healing applied',
extra_health_granted: 'Extra Health granted',
extra_health_changed: 'Extra Health changed',
movement_snapshot: 'Movement snapshot',
proximity_snapshot: 'Proximity snapshot',
firing_started: 'Firing started',
firing_stopped: 'Firing stopped',
reload_started: 'Reload started',
reload_completed: 'Reload completed',
weapon_swapped: 'Weapon swapped',
tick: 'Simulation tick',
} as Record<string, string>
)[clause.when],
);
}
});
it('covers conditions, references, reducers, state, mutations, and outcome metadata', () => {
const program = baseProgram({
scenarioInputs: [
{
key: 'stacks-to-add',
label: 'Stacks to add',
kind: 'number',
defaultValue: 1.25,
bestCaseValue: 2.5,
min: 0,
max: 3,
},
],
pools: [
{
key: 'stacks',
label: 'Momentum stacks',
scope: 'agent',
initial: 0,
min: 0,
max: 4,
bestCaseValue: 3,
expiresAfterSeconds: 8.75,
},
{
key: 'target-mark',
label: 'Target mark',
scope: 'target',
initial: 0,
min: 0,
max: 1,
},
{
key: 'cooldown',
label: 'Momentum cooldown',
scope: 'agent',
initial: 0,
min: 0,
max: 1,
expiresAfterSeconds: 12,
},
],
clauses: [
{
id: 'advanced',
label: 'Build momentum',
when: 'weapon_hit',
if: {
kind: 'all',
predicates: [
{
kind: 'compare',
left: { source: 'event', path: 'values.distanceMeters' },
operator: 'lte',
right: 15.5,
},
{
kind: 'not',
predicate: {
kind: 'exists',
value: { source: 'event', path: 'values.critical' },
},
},
{
kind: 'compare',
left: { source: 'attribute', attributeId: 'firepower' },
operator: 'gte',
right: { source: 'scenario', key: 'stacks-to-add' },
},
],
},
reducer: {
kind: 'count',
threshold: 2,
reset: 'subtract',
groupBy: { source: 'event', path: 'targetId' },
},
cooldownSeconds: 4.5,
weight: {
op: 'clamp',
value: { ref: { source: 'pool', pool: 'stacks' } },
min: 0,
max: 1,
},
procChancePercent: 37.5,
requiresAssignedAbilityActive: true,
mutations: [
{
kind: 'add',
pool: 'stacks',
value: { ref: { source: 'scenario', key: 'stacks-to-add' } },
},
{
kind: 'set',
pool: 'target-mark',
entity: { kind: 'event_target' },
value: 1,
},
{ kind: 'consume', pool: 'stacks', value: 0.5 },
{
kind: 'derive',
pool: 'stacks',
value: { op: 'floor', value: { ref: { source: 'reducer' } } },
},
{
kind: 'transfer',
pool: 'target-mark',
from: { kind: 'event_target' },
to: { kind: 'agent' },
value: 'all',
clearSource: false,
},
{
kind: 'extend_duration',
target: 'assigned_ability',
seconds: 2.25,
},
{
kind: 'start_cooldown_on_max',
stackPool: 'stacks',
cooldownPool: 'cooldown',
},
],
outcomes: [
{
id: 'buff',
label: 'Momentum buff',
phase: 'while_active',
kind: 'attribute_modifier',
scope: 'self',
value: {
op: 'multiply',
left: 2,
right: {
ref: { source: 'attribute', attributeId: 'firepower' },
},
},
basis: 'flat',
attributeId: 'firepower',
statePool: 'stacks',
perStack: true,
minStacks: 1,
maxStacks: 4,
combatTiming: 'current',
probabilityPercent: 88.25,
},
{
id: 'restore',
label: 'Regeneration',
phase: 'after_event',
kind: 'restoration',
scope: 'self_and_allies',
value: 10,
basis: 'max_health_percent',
resource: 'health',
delivery: 'continuous',
durationSeconds: 3.5,
},
{
id: 'cost',
label: 'Ammo cost',
phase: 'on_event',
kind: 'resource_cost',
scope: 'self',
value: 2,
basis: 'flat',
resource: 'ammo',
delivery: 'instant',
},
{
id: 'reload',
label: 'Reload inactive weapon',
phase: 'on_exit',
kind: 'weapon_reload',
scope: 'inactive_weapon',
value: { op: 'add', left: 1.125, right: 0.25 },
basis: 'flat',
},
{
id: 'area',
label: 'Area strike',
phase: 'on_event',
kind: 'direct_damage',
scope: 'target',
value: 15,
basis: 'weapon_base_percent',
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 4,
},
},
{
id: 'marker',
label: 'Marker',
phase: 'on_enter',
kind: 'marker',
scope: 'assigned_ability',
value: 1,
basis: 'flat',
},
],
},
],
});
const result = render(program, {
resolveAttributeLabel: (id) =>
id === 'firepower' ? 'Firepower' : undefined,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.variants[0].text).toMatchInlineSnapshot(`
"Base
The “Stacks to add” scenario input is a number, from 0, to 3, default 1.25, maximum 2.5.
State “Momentum cooldown” tracks the Agent, starting at 0, from 0 to 1. It expires 12 seconds after it is written. State “Momentum stacks” tracks the Agent, starting at 0, from 0 to 4. The maximum scenario value is 3. It expires 8.75 seconds after it is written. State “Target mark” tracks each target, starting at 0, from 0 to 1.
Build momentum: When Weapon hit occurs, provided that all of these conditions are met: (the event’s Target distance is at most 15.5); (it is not the case that the event’s Critical hit is present); (the Firepower Attribute value is at least the “Stacks to add” scenario input), after 2 matching events grouped by the event’s Target ID while retaining any matches beyond the threshold, only while the assigned ability is active, with a weight of the value of the “Momentum stacks” state for the Agent limited from 0 to 1, with a 37.5% chance to trigger, then unavailable for 4.5 seconds. Add the “Stacks to add” scenario input to the “Momentum stacks” state for the Agent. Set 1 as the “Target mark” state for the event target. Consume 0.5 from the “Momentum stacks” state for the Agent. Derive the whole-number value of the reduced value as the “Momentum stacks” state for the Agent. Transfer all of the “Target mark” state from the event target to the Agent, without clearing the source. Extend the assigned ability by 2.25 seconds. Start the “Momentum cooldown” cooldown when the “Momentum stacks” state reaches its maximum. Momentum buff: while the state is active, modify “Firepower” by the product of 2 and the Firepower Attribute value for the Agent using the “Momentum stacks” state per stack when stacks are from 1 through 4 immediately with a 88.25% chance. Regeneration: after this event, restore a total of 10% of maximum Health to Health for the Agent and allies continuously over 3.5 seconds. Ammo cost: when this event occurs, spend 2 of Ammo from the Agent immediately. Reload inactive weapon: when the state ends, reload the inactive weapon after the sum of 1.125 and 0.25 seconds. Area strike: when this event occurs, deal damage equal to 15% of weapon base damage to the target, affecting valid enemies within 4 meters of the event target immediately. Marker: when the state begins, record a marker of 1 for the assigned ability immediately."
`);
});
it('renders a custom variant label and resolves all attribute references', () => {
const result = renderPublicMechanicPrograms(
[
{
key: 'rank-1',
label: 'Rank 1',
program: baseProgram({
clauses: [
{
...baseProgram().clauses[0],
if: {
kind: 'exists',
value: { source: 'attribute', attributeId: 'armor' },
},
outcomes: [
{
...baseProgram().clauses[0].outcomes![0],
kind: 'attribute_modifier',
attributeId: 'armor',
scope: 'self',
},
],
},
],
}),
},
],
{ resolveAttributeLabel: (id) => (id === 'armor' ? 'Armor' : null) },
);
expect(result).toMatchObject({
ok: true,
variants: [{ key: 'rank-1', label: 'Rank 1' }],
});
if (result.ok) expect(result.variants[0].text).toContain('“Armor”');
});
it('fails closed for an invalid program before attempting to render it', () => {
const invalid = baseProgram({
clauses: [{ ...baseProgram().clauses[0], when: 'not-an-event' as never }],
});
const result = render(invalid);
expect(result).toMatchObject({
ok: false,
errors: [{ code: 'invalid_program', path: 'variants[base]' }],
});
});
it('validates true predicates instead of dropping their structure', () => {
const program = baseProgram({
clauses: [
{
...baseProgram().clauses[0],
if: { kind: 'true', unexpected: true } as never,
},
],
});
const result = render(program);
expect(result).toMatchObject({
ok: false,
errors: [
{
code: 'unsupported_fragment',
path: 'variants[base].clauses[0].if.unexpected',
},
],
});
});
it('rejects overlapping or non-array input shapes', () => {
const result = renderPublicMechanicPrograms({
variants: [{ key: 'base', program: baseProgram() }],
} as never);
expect(result).toMatchObject({
ok: false,
errors: [{ code: 'invalid_program', path: 'variants' }],
});
});
it('rejects combat references outside the combat reference registry', () => {
const result = render(
baseProgram({
clauses: [
{
...baseProgram().clauses[0],
outcomes: [
{
...baseProgram().clauses[0].outcomes![0],
value: {
ref: { source: 'combat', path: 'maxHealth' },
},
},
],
},
],
}),
);
expect(result).toMatchObject({
ok: false,
errors: [
{
code: 'unsupported_fragment',
path: 'variants[base].clauses[0].outcomes[0].value.ref.path',
},
],
});
});
it('fails closed when an Attribute ID cannot be resolved', () => {
const result = render(
baseProgram({
clauses: [
{
...baseProgram().clauses[0],
outcomes: [
{
...baseProgram().clauses[0].outcomes![0],
kind: 'attribute_modifier',
attributeId: 'missing-attribute',
},
],
},
],
}),
{ resolveAttributeLabel: () => undefined },
);
expect(result).toMatchObject({
ok: false,
errors: [
{
code: 'unresolved_attribute',
path: 'variants[base].clauses[0].outcomes[0].attributeId',
},
],
});
});
it('does not render ignored combat timing on flat outcomes', () => {
const program = baseProgram({
clauses: [
{
...baseProgram().clauses[0],
outcomes: [
{
...baseProgram().clauses[0].outcomes![0],
basis: 'flat',
combatTiming: 'pre_event',
},
],
},
],
});
const result = render(program);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.variants[0].text).not.toContain('before this event');
expect(result.variants[0].text).not.toContain('using current values');
}
});
it('fails closed for targeting the runtime does not expand', () => {
const result = render(
baseProgram({
clauses: [
{
...baseProgram().clauses[0],
outcomes: [
{
...baseProgram().clauses[0].outcomes![0],
targeting: {
kind: 'within_radius',
population: 'enemies',
radiusMeters: 4,
},
},
],
},
],
}),
);
expect(result).toMatchObject({
ok: false,
errors: [
{
code: 'unsupported_fragment',
path: 'variants[base].clauses[0].outcomes[0].targeting',
},
],
});
});
it('sorts set-like inputs and pools by semantic key', () => {
const first = baseProgram({
scenarioInputs: [
{
key: 'z-input',
label: 'Z input',
kind: 'toggle',
defaultValue: false,
bestCaseValue: true,
},
{
key: 'a-input',
label: 'A input',
kind: 'toggle',
defaultValue: false,
bestCaseValue: true,
},
],
pools: [
{ key: 'z-pool', label: 'Z pool', scope: 'agent', initial: 0 },
{ key: 'a-pool', label: 'A pool', scope: 'agent', initial: 0 },
],
});
const second = baseProgram({
scenarioInputs: [...first.scenarioInputs!].reverse(),
pools: [...first.pools!].reverse(),
});
const firstResult = render(first);
const secondResult = render(second);
expect(firstResult.ok && secondResult.ok).toBe(true);
if (firstResult.ok && secondResult.ok)
expect(firstResult.variants[0].text).toBe(secondResult.variants[0].text);
});
it('requires renderable labels instead of humanizing generated IDs', () => {
const missingVariantLabel = renderPublicMechanicPrograms([
{ key: 'generated-variant-id', program: baseProgram() },
]);
expect(missingVariantLabel).toMatchObject({
ok: false,
errors: [
{
code: 'unsupported_fragment',
path: 'variants[generated-variant-id].label',
},
],
});
const missingClauseLabel = render(
baseProgram({
clauses: [{ ...baseProgram().clauses[0], label: undefined }],
}),
);
expect(missingClauseLabel).toMatchObject({
ok: false,
errors: [
{
code: 'unsupported_fragment',
path: 'variants[base].clauses[0].label',
},
],
});
const missingOutcomeLabel = render(
baseProgram({
clauses: [
{
...baseProgram().clauses[0],
outcomes: [
{
...baseProgram().clauses[0].outcomes![0],
label: undefined as never,
},
],
},
],
}),
);
expect(missingOutcomeLabel).toMatchObject({
ok: false,
errors: [
{
code: 'unsupported_fragment',
path: 'variants[base].clauses[0].outcomes[0].label',
},
],
});
const missingPoolLabel = render(
baseProgram({
pools: [
{
key: 'generated-pool-id',
label: undefined as never,
scope: 'agent',
initial: 0,
},
],
}),
);
expect(missingPoolLabel).toMatchObject({
ok: false,
errors: [
{ code: 'unsupported_fragment', path: 'variants[base].pools[0].label' },
],
});
});
it('fails closed for unsupported fields instead of leaking them', () => {
const program = baseProgram();
(
program.clauses[0] as MechanicProgram['clauses'][number] & {
script?: string;
}
).script = 'WHEN raw';
const result = render(program);
expect(result).toMatchObject({
ok: false,
errors: [
{
code: 'unsupported_fragment',
path: 'variants[base].clauses[0].script',
},
],
});
});
it('does not round represented numeric values or depend on locale settings', () => {
const program = baseProgram({
clauses: [
{
...baseProgram().clauses[0],
outcomes: [
{ ...baseProgram().clauses[0].outcomes![0], value: 1.23456789 },
],
},
],
});
const result = render(program);
expect(result.ok).toBe(true);
if (result.ok) expect(result.variants[0].text).toContain('1.23456789');
});
});
File diff suppressed because it is too large Load Diff
+98
View File
@@ -1355,6 +1355,104 @@ describe('structured mechanic runtime', () => {
expect(maximum.finalState[0].value).toBe(4);
});
it('preserves an explicit zero clause weight', () => {
const result = evaluateMechanicProgram(
{
id: 'zero-weight',
name: 'Zero weight',
clauses: [
{
id: 'zero-weight-clause',
when: 'agent_hit',
reducer: { kind: 'none' },
weight: 0,
mutations: [],
},
],
},
[{ kind: 'agent_hit', at: 0 }],
custom(1),
);
expect(result.emissions).toHaveLength(1);
expect(result.emissions[0].weight).toBe(0);
expect(result.estimatedActivationCount).toBe(0);
});
it.each([0, 0.5])(
'keeps a weighted reload delay intact at weight %s',
(weight) => {
const result = evaluateMechanicProgram(
{
id: 'weighted-reload',
name: 'Weighted reload',
clauses: [
{
id: 'weighted-reload-clause',
when: 'weapon_hit',
reducer: { kind: 'none' },
weight,
mutations: [],
outcomes: [
{
id: 'reload',
label: 'Reload',
phase: 'on_event',
kind: 'weapon_reload',
scope: 'equipped_weapon',
value: 5,
basis: 'flat',
},
],
},
],
},
[{ kind: 'weapon_hit', at: 1 }],
custom(10),
);
expect(result.outcomes).toHaveLength(1);
expect(result.outcomes[0]).toMatchObject({
at: 6,
weight,
resolvedValue: 5,
appliedValue: 5,
});
},
);
it('preserves a zero weight for maximum-mode passive outcomes', () => {
const result = evaluateMechanicProgram(
{
id: 'zero-weight-maximum',
name: 'Zero weight maximum',
clauses: [
{
id: 'zero-weight-maximum-clause',
label: 'Maximum passive',
when: 'weapon_hit',
reducer: { kind: 'none' },
weight: 0,
mutations: [],
outcomes: [
{
id: 'marker',
label: 'Passive marker',
phase: 'while_active',
kind: 'marker',
scope: 'self',
value: 1,
basis: 'flat',
},
],
},
],
},
[],
{ mode: 'maximum', encounterSeconds: 10 },
);
expect(result.outcomes).toHaveLength(1);
expect(result.outcomes[0]).toMatchObject({ weight: 0, appliedValue: 0 });
});
it('derives proximity stacks directly from a builder-controlled snapshot', () => {
const program: MechanicProgram = {
id: 'nearby-allies',
+13 -6
View File
@@ -1633,8 +1633,10 @@ function emitOutcomes(
}
const targetIds = outcomeTargetIds(clause, outcome, context);
if (!targetIds) continue;
const emittedAt =
outcome.kind === 'weapon_reload' ? at + Math.max(0, appliedValue) : at;
if (outcome.kind === 'weapon_reload') {
appliedValue = Math.max(0, resolvedValue);
}
const emittedAt = outcome.kind === 'weapon_reload' ? at + appliedValue : at;
for (const targetId of targetIds) {
emissions.push({
clauseId: clause.id,
@@ -3115,6 +3117,10 @@ export function evaluateMechanicProgram(
.filter((pool): pool is string => Boolean(pool))
.map((pool) => [pool, stateValue(pool, undefined, context)]),
);
const weight =
clause.weight !== undefined
? clamp(numericValue(clause.weight, context), 0, 1)
: 1;
appendScheduledOutcomes(
outcomes,
emitOutcomes(
@@ -3126,7 +3132,7 @@ export function evaluateMechanicProgram(
},
'after',
0,
1,
weight,
before,
context,
continuousDeliveries,
@@ -3248,9 +3254,10 @@ export function evaluateMechanicProgram(
for (const reduction of reduced) {
if (reduction.at + EPSILON < (cooldowns.get(stateKey) ?? 0)) continue;
const context = { ...baseContext, reducerValue: reduction.value };
const weight = clause.weight
? clamp(numericValue(clause.weight, context), 0, 1)
: 1;
const weight =
clause.weight !== undefined
? clamp(numericValue(clause.weight, context), 0, 1)
: 1;
const relevantPools = new Set(
(clause.outcomes ?? [])
.map((outcome) => outcome.statePool)