Adding skills & isac talent updates

This commit is contained in:
jb
2026-07-29 20:56:22 -04:00
parent f1b473ffa8
commit 65a4987cad
68 changed files with 5486 additions and 569 deletions
+22 -19
View File
File diff suppressed because one or more lines are too long
+460
View File
@@ -2,12 +2,31 @@ import { describe, expect, it } from 'vitest';
import { evaluateMechanicProgram, type MechanicProgram } from '$lib/calculations/mechanic';
import { createDraft } from './loadout.js';
import {
buildSkillDeploymentEvents,
buildWeaponMechanicTimeline,
equippedConditionalProgram,
mechanicModifiersAt,
skillModifierWindowsForMechanic,
weaponCriticalChance
} from './loadout-combat.js';
import { calculateSkillDps, type SkillAbilityInput } from '$lib/calculations/skill';
describe('loadout mechanic timeline', () => {
function magazineBandsFor(magazine: number, encounterSeconds = magazine - 1) {
const profile = createDraft().combatProfile;
profile.encounterSeconds = encounterSeconds;
profile.hitRate = 0;
profile.shotsPerBurst = 100;
profile.burstPauseSeconds = 0;
return buildWeaponMechanicTimeline(
{ rpm: 60, magazine, reload_speed: 0 },
profile
)
.filter((event) => event.kind === 'shot_fired')
.map((event) => event.values?.magazineTwentyPercentBandsCrossed);
}
it('emits deterministic critical hits from modeled critical chance', () => {
const profile = createDraft().combatProfile;
profile.encounterSeconds = 4;
@@ -27,6 +46,86 @@ describe('loadout mechanic timeline', () => {
expect(criticalHits).toEqual([false, true, false, true, false]);
});
it('marks every fired shot with its deterministic hit result', () => {
const profile = createDraft().combatProfile;
profile.encounterSeconds = 4;
profile.hitRate = 50;
profile.shotsPerBurst = 100;
profile.burstPauseSeconds = 0;
const hitResults = buildWeaponMechanicTimeline(
{ rpm: 60, magazine: 100, reload_speed: 0 },
profile
)
.filter((event) => event.kind === 'shot_fired')
.map((event) => event.values?.hit);
expect(hitResults).toEqual([false, true, false, true, false]);
});
it('marks enemy kills from deterministic critical killing shots', () => {
const profile = createDraft().combatProfile;
profile.encounterSeconds = 30;
profile.killsPerMinute = 4;
const criticalKills = buildWeaponMechanicTimeline(
{ rpm: 60, magazine: 100, reload_speed: 0 },
profile,
50
)
.filter((event) => event.kind === 'enemy_killed')
.map((event) => event.values?.critical);
expect(criticalKills).toEqual([false, true]);
});
it('marks enemy kills from the configured headshot-kill rate', () => {
const profile = createDraft().combatProfile;
profile.encounterSeconds = 30;
profile.killsPerMinute = 8;
profile.headshotKillRate = 50;
const headshotKills = buildWeaponMechanicTimeline(
{ rpm: 60, magazine: 100, reload_speed: 0 },
profile
)
.filter((event) => event.kind === 'enemy_killed')
.map((event) => event.values?.headshot);
expect(headshotKills).toEqual([false, true, false, true]);
});
it('marks reload starts from the pre-reload empty-magazine state', () => {
const profile = createDraft().combatProfile;
profile.encounterSeconds = 2;
profile.shotsPerBurst = 100;
profile.burstPauseSeconds = 0;
const emptyStates = buildWeaponMechanicTimeline(
{ rpm: 60, magazine: 2, reload_speed: 5 },
profile
)
.filter((event) => event.kind === 'reload_started')
.map((event) => event.values?.emptyMagazine);
expect(emptyStates).toEqual([true]);
});
it('emits post-shot 20% magazine-band crossings for an even magazine', () => {
expect(magazineBandsFor(10)).toEqual([0, 1, 0, 1, 0, 1, 0, 1, 0, 0]);
});
it.each([
{ magazine: 7, expected: [0, 1, 1, 0, 1, 1, 0] },
{ magazine: 3, expected: [1, 2, 1] }
])('counts every crossed band for an odd/small $magazine-round magazine', ({ magazine, expected }) => {
expect(magazineBandsFor(magazine)).toEqual(expected);
});
it('restarts magazine-band crossings after reloading', () => {
expect(magazineBandsFor(5, 5)).toEqual([1, 1, 1, 1, 0, 1]);
});
it('derives critical chance through the configured binding transform', () => {
expect(weaponCriticalChance(
{ critical: 80 },
@@ -40,6 +139,367 @@ describe('loadout mechanic timeline', () => {
}]
)).toBe(30);
});
it('publishes one standardized movement state and lets sprinting win', () => {
const profile = createDraft().combatProfile;
profile.encounterSeconds = 1;
const moving = buildWeaponMechanicTimeline(
{ rpm: 60, magazine: 100, reload_speed: 0 },
profile,
0,
{ scenarioInputs: { moving: true, sprinting: false } }
).filter((event) => event.kind === 'movement_snapshot');
const sprinting = buildWeaponMechanicTimeline(
{ rpm: 60, magazine: 100, reload_speed: 0 },
profile,
0,
{ scenarioInputs: { moving: true, sprinting: true } }
).filter((event) => event.kind === 'movement_snapshot');
expect(moving.map((event) => event.values)).toEqual([
{ state: 'moving' },
{ state: 'moving' }
]);
expect(sprinting.every((event) => event.values?.state === 'sprinting')).toBe(true);
});
it('falls back to a documented Tier I mechanic when equipment Tier II is absent', () => {
const persistedRule = {
rule: {
id: 'tier-i-only',
source_type: 'talent',
source_id: 'exotic-talent',
configured: 1,
script_version: 1,
simulation_readiness: 'ready',
scenario_inputs_json: '[]'
},
statePools: [],
clauses: [{
id: 'shot-clause',
rule_id: 'tier-i-only',
clause_key: 'shot',
label: 'Shot',
trigger_id: null,
event_kind: 'shot_fired',
parameters_json: '{}',
conditions_json: JSON.stringify({ kind: 'true' }),
reducer_json: JSON.stringify({ kind: 'none' }),
emission_kind: 'each_match',
state_pool_id: null,
mutations_json: '[]',
cooldown_seconds: null,
output_branch: 'active',
sort_order: 1,
active: 1
}],
outcomes: [{
id: 'weapon-damage-i',
rule_id: 'tier-i-only',
clause_id: 'shot-clause',
state_pool_id: null,
effect_key: 'Weapon Damage',
variant_key: 'I',
outcome_key: 'weapon-damage',
application_kind: 'active_modifier',
effect_scope: 'equipped_weapon',
stat_attribute_id: 'weapon-damage',
value: 15,
value_expression_json: null,
value_basis: 'flat',
resource_kind: null,
combat_timing: null,
delivery_mode: 'instant',
delivery_duration_seconds: null,
targeting_json: null,
value_scaling: 'flat',
unit: '%',
damage_basis: null,
application_phase: 'on_event',
min_state_value: null,
max_state_value: null,
description_slot: 1,
sort_order: 1
}],
triggers: []
};
const adapted = equippedConditionalProgram({
key: 'weapon:0:exotic-talent',
tier: 2,
rule: persistedRule
});
expect(adapted?.clauses[0].outcomes?.[0]).toMatchObject({
id: 'weapon-damage-i',
value: 15
});
});
});
describe('movement-charge exotic mechanic wiring', () => {
const ability: SkillAbilityInput = {
id: 'skill-a',
baseDamage: 100,
damageType: 'kinetic',
delivery: { type: 'direct', hits: 1 },
castSeconds: 0,
cooldownSeconds: 10,
targetCap: 1,
targetCount: 1,
chargePolicy: { type: 'none' },
activation: { mode: 'on_cooldown' }
};
const program: MechanicProgram = {
id: 'movement-charge-exotic',
name: 'Movement Charge Exotic',
scenarioInputs: [
{
key: 'moving',
label: 'Moving',
kind: 'toggle',
defaultValue: false,
bestCaseValue: true
},
{
key: 'sprinting',
label: 'Sprinting',
kind: 'toggle',
defaultValue: false,
bestCaseValue: true
}
],
pools: [
{
key: 'movement-charge',
label: 'Movement charge',
scope: 'agent',
initial: 0,
min: 0,
max: 40,
bestCaseValue: 40
},
{
key: 'kill-speed',
label: 'Kill movement speed',
scope: 'agent',
initial: 0,
min: 0,
max: 1,
expiresAfterSeconds: 10
}
],
clauses: [
{
id: 'gain-while-moving',
when: 'movement_snapshot',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.state' },
operator: 'eq',
right: 'moving'
},
reducer: { kind: 'periodic', intervalSeconds: 1 },
mutations: [{ kind: 'add', pool: 'movement-charge', value: 4 }]
},
{
id: 'gain-while-sprinting',
when: 'movement_snapshot',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.state' },
operator: 'eq',
right: 'sprinting'
},
reducer: { kind: 'periodic', intervalSeconds: 1 },
mutations: [{ kind: 'add', pool: 'movement-charge', value: 8 }]
},
{
id: 'spend-on-shot',
when: 'shot_fired',
if: {
kind: 'compare',
left: { source: 'pool', pool: 'movement-charge' },
operator: 'gt',
right: 0
},
reducer: { kind: 'none' },
mutations: [{ kind: 'consume', pool: 'movement-charge', value: 1 }],
outcomes: [{
id: 'shot-weapon-damage',
label: 'Shot Weapon Damage',
phase: 'on_event',
kind: 'attribute_modifier',
scope: 'equipped_weapon',
value: 15,
basis: 'flat',
attributeId: 'weapon-damage'
}]
},
{
id: 'spend-on-skill',
when: 'skill_deployed',
if: {
kind: 'compare',
left: { source: 'pool', pool: 'movement-charge' },
operator: 'gte',
right: 10
},
reducer: { kind: 'none' },
mutations: [{ kind: 'consume', pool: 'movement-charge', value: 10 }],
outcomes: [{
id: 'deployment-skill-damage',
label: 'Deployment Skill Damage',
phase: 'on_event',
kind: 'attribute_modifier',
scope: 'all_skills',
value: 15,
basis: 'flat',
attributeId: 'skill-damage'
}]
},
{
id: 'speed-on-kill',
when: 'enemy_killed',
reducer: { kind: 'none' },
mutations: [{ kind: 'set', pool: 'kill-speed', value: 1 }],
outcomes: [{
id: 'kill-movement-speed',
label: 'Kill Movement Speed',
phase: 'while_active',
kind: 'attribute_modifier',
scope: 'self',
value: 10,
basis: 'flat',
attributeId: 'movement-speed',
statePool: 'kill-speed'
}]
}
]
};
it('gains stacks, consumes them on shots and eligible deployments, and applies timed outcomes', () => {
const profile = createDraft().combatProfile;
profile.encounterSeconds = 15;
const skillDeployments = buildSkillDeploymentEvents([ability], 15);
const events = [
...buildWeaponMechanicTimeline(
{ rpm: 60, magazine: 100, reload_speed: 0 },
profile,
0,
{
scenarioInputs: { moving: true, sprinting: false },
skillDeploymentEvents: skillDeployments
}
).filter((event) =>
event.kind === 'movement_snapshot' || event.kind === 'skill_deployed'),
{ kind: 'shot_fired' as const, at: 3.1 },
{ kind: 'enemy_killed' as const, at: 4 }
];
const result = evaluateMechanicProgram(
program,
events,
{
mode: 'custom',
encounterSeconds: 15,
inputs: { moving: true, sprinting: false }
}
);
expect(skillDeployments).toEqual([
{
kind: 'skill_deployed',
at: 0,
targetId: 'skill-a',
values: { abilityId: 'skill-a' }
},
{
kind: 'skill_deployed',
at: 10,
targetId: 'skill-a',
values: { abilityId: 'skill-a' }
}
]);
expect(result.status).toBe('OK');
expect(result.emissions.filter((emission) => emission.clauseId === 'spend-on-skill'))
.toHaveLength(1);
expect(result.emissions.find((emission) => emission.clauseId === 'spend-on-shot')?.mutations[0])
.toMatchObject({ before: 12, after: 11 });
expect(result.emissions.find((emission) => emission.clauseId === 'spend-on-skill')?.mutations[0])
.toMatchObject({ before: 39, after: 29 });
expect(result.outcomes).toEqual(expect.arrayContaining([
expect.objectContaining({
outcomeId: 'shot-weapon-damage',
at: 3.1,
appliedValue: 15
}),
expect.objectContaining({
outcomeId: 'deployment-skill-damage',
at: 10,
targetId: 'skill-a',
appliedValue: 15
}),
expect.objectContaining({
outcomeId: 'kill-movement-speed',
at: 4,
appliedValue: 10
})
]));
expect(result.finalState).toContainEqual(expect.objectContaining({
pool: 'kill-speed',
value: 0,
expiresAt: null
}));
const source = {
status: 'OK' as const,
result,
program,
encounterSeconds: 15,
events
};
const skillWindows = skillModifierWindowsForMechanic(
source,
null,
new Set(['skill-a']),
new Set(['skill-damage'])
);
const movementWindows = skillModifierWindowsForMechanic(
source,
null,
new Set(['skill-a']),
new Set(['movement-speed'])
);
expect(skillWindows).toEqual([{
startSeconds: 10,
endSeconds: 15,
attributes: { 'skill-damage': 15 },
abilityIds: ['skill-a']
}]);
expect(movementWindows).toEqual([{
startSeconds: 4,
endSeconds: 14,
attributes: { 'movement-speed': 10 },
abilityIds: undefined
}]);
const skillResult = calculateSkillDps({
rules: {
revisionId: 'test',
bindings: [{ attributeId: 'skill-damage', stage: 'skill_damage', operator: 'add' }]
},
abilities: [ability],
attributes: {},
modifierWindows: skillWindows,
assumptions: { encounterSeconds: 15, targetCount: 1 }
});
expect(skillResult.status).toBe('OK');
if (skillResult.status === 'OK') {
expect(skillResult.abilities[0].activationCount).toBe(2);
expect(skillResult.abilities[0].damage.singleTargetEncounterDamage).toBe(215);
}
});
});
describe('per-shot mechanic modifiers', () => {
+312 -122
View File
@@ -17,8 +17,12 @@ import {
import { adaptConditionalEffectProgram } from '$lib/calculations/mechanic-persistence';
import {
calculateSkillDps,
skillActivationTimes,
type SkillAbilityInput,
type SkillAttributeBinding,
type SkillCalculation,
type SkillCalculationInput,
type SkillCalculationRules,
type SkillModifierWindow
} from '$lib/calculations/skill';
import { gearSlots, scaled, type LoadoutDraft } from '$lib/builder/loadout';
@@ -415,41 +419,51 @@ function interpolatedConditionalProgram(
};
}
function equippedConditionalProgram(equipped: any): MechanicProgram | null {
export function equippedConditionalProgram(equipped: any): MechanicProgram | null {
if (String(equipped.key).startsWith('chips:')) {
return interpolatedConditionalProgram(equipped.rule, 'minimum', 'maximum', Number(equipped.tier), 3);
}
if (String(equipped.key).startsWith('protocol:')) {
return conditionalProgram(equipped.rule, 'base');
}
return conditionalProgram(
equipped.rule,
String(equipped.key).startsWith('weapon:') || String(equipped.key).startsWith('gear:')
? (Number(equipped.tier) >= 2 ? 'II' : 'I')
: 'base'
);
const equipmentTalent = String(equipped.key).startsWith('weapon:')
|| String(equipped.key).startsWith('gear:');
const variant = equipmentTalent
? (Number(equipped.tier) >= 2 ? 'II' : 'I')
: 'base';
return conditionalProgram(equipped.rule, variant)
?? (equipmentTalent && variant === 'II'
? conditionalProgram(equipped.rule, 'I')
: null);
}
type ActiveMechanicEvaluation = {
export type ActiveMechanicEvaluation = {
status: 'OK';
result: MechanicEvaluation;
program: MechanicProgram;
encounterSeconds: number;
events?: MechanicEvent[];
};
function mechanicScenarioInputs(program: MechanicProgram, configured: unknown) {
function mechanicScenarioInputs(
program: MechanicProgram,
configured: unknown,
maximum = false
) {
const values = configured && typeof configured === 'object' && !Array.isArray(configured)
? configured as Record<string, number | boolean>
: {};
return Object.fromEntries((program.scenarioInputs ?? []).map((input) => [
input.key,
values[input.key] ?? input.defaultValue ?? (input.kind === 'toggle' ? false : 0)
maximum
? input.bestCaseValue ?? input.defaultValue ?? (input.kind === 'toggle' ? false : 0)
: values[input.key] ?? input.defaultValue ?? (input.kind === 'toggle' ? false : 0)
]));
}
function evaluateConfiguredMechanic(
program: MechanicProgram,
events: MechanicEvent[],
buildEvents: (scenarioInputs: Record<string, number | boolean>) => MechanicEvent[],
state: any,
profile: LoadoutDraft['combatProfile'],
weaponBaseDamage: number,
@@ -461,12 +475,14 @@ function evaluateConfiguredMechanic(
if (calculationMode !== 'maximum' && state.mode === 'off') return null;
const encounterSeconds = Math.max(1, Number(profile.encounterSeconds));
const maximum = calculationMode === 'maximum' || state.mode === 'peak';
const scenarioInputs = mechanicScenarioInputs(program, state.inputs, maximum);
const events = buildEvents(scenarioInputs);
const result = evaluateMechanicProgram(
program,
events,
maximum
? { mode: 'maximum', encounterSeconds }
: { mode: 'custom', encounterSeconds, inputs: mechanicScenarioInputs(program, state.inputs) },
: { mode: 'custom', encounterSeconds, inputs: scenarioInputs },
{
maxHealth: Math.max(1, maxHealth),
currentHealth: Math.max(0, maxHealth * Number(profile.healthPercent) / 100),
@@ -476,7 +492,7 @@ function evaluateConfiguredMechanic(
},
equipmentContext
);
return result.status === 'OK' ? { status: 'OK', result, program, encounterSeconds } : null;
return result.status === 'OK' ? { status: 'OK', result, program, encounterSeconds, events } : null;
}
type ModifierInterval = {
@@ -489,6 +505,7 @@ type ModifierInterval = {
outcomeId: string;
phase: MechanicOutcome['phase'];
activationEvent: MechanicEvent['kind'];
activationAbilityId: string | null;
endAtNextActivation: boolean;
};
@@ -497,17 +514,42 @@ function modifierIntervals(source: ActiveMechanicEvaluation): ModifierInterval[]
.filter((emission) => emission.kind === 'attribute_modifier' && emission.attributeId)
.sort((left, right) => left.at - right.at);
return emissions.flatMap((emission, index) => {
const clause = source.program.clauses.find((candidate) =>
candidate.outcomes?.some((outcome) => outcome.id === emission.outcomeId));
const clause = source.program.clauses.find((candidate) => candidate.id === emission.clauseId);
const definition = clause?.outcomes?.find((candidate) => candidate.id === emission.outcomeId);
if (!clause || !definition) return [];
const next = emissions.slice(index + 1).find((candidate) => candidate.outcomeId === emission.outcomeId);
const activationAbilityId = definition.phase === 'on_event' && clause.when === 'skill_deployed'
? emission.targetId
: null;
const nextDeploymentAt = activationAbilityId
? source.events?.find((event) =>
event.kind === 'skill_deployed'
&& event.targetId === activationAbilityId
&& event.at > emission.at + 0.000_001)?.at
: undefined;
const next = emissions.slice(index + 1).find((candidate) =>
candidate.outcomeId === emission.outcomeId
&& (activationAbilityId === null || candidate.targetId === activationAbilityId));
const pool = definition.statePool
? source.program.pools?.find((candidate) => candidate.key === definition.statePool)
: null;
const deactivatedAt = definition.statePool
? source.result.emissions.find((candidate) =>
candidate.at >= emission.at
&& candidate.mutations.some((mutation) =>
mutation.pool === definition.statePool
&& mutation.after !== undefined
&& mutation.after <= 0
)
)?.at
: undefined;
const naturalEnd = definition.phase === 'while_active'
? emission.at + Number(pool?.expiresAfterSeconds ?? source.encounterSeconds)
: emission.at;
? Math.min(
emission.at + Number(pool?.expiresAfterSeconds ?? source.encounterSeconds),
deactivatedAt ?? source.encounterSeconds
)
: activationAbilityId
? nextDeploymentAt ?? source.encounterSeconds
: emission.at;
const end = Math.min(source.encounterSeconds, next?.at ?? naturalEnd, naturalEnd);
if (
end <= emission.at
@@ -521,11 +563,14 @@ function modifierIntervals(source: ActiveMechanicEvaluation): ModifierInterval[]
stateValue: emission.stateValue,
attributeId: String(emission.attributeId),
scope: emission.scope,
outcomeId: emission.outcomeId,
phase: definition.phase,
activationEvent: clause.when,
endAtNextActivation: next !== undefined && next.at <= naturalEnd
}];
outcomeId: emission.outcomeId,
phase: definition.phase,
activationEvent: clause.when,
activationAbilityId,
endAtNextActivation: activationAbilityId
? nextDeploymentAt !== undefined
: next !== undefined && next.at <= naturalEnd
}];
});
}
@@ -555,15 +600,22 @@ const TIMELINE_EVENT_ORDER: Readonly<Record<MechanicEvent['kind'], number>> = {
weapon_swapped: 7,
enemy_killed: 8,
assist: 9,
skill_deployed: 10,
skill_hit: 11,
agent_hit: 12,
health_damage_taken: 13,
healing_applied: 14,
extra_health_granted: 15,
tick: 16,
proximity_snapshot: 17,
movement_snapshot: 18
ally_downed: 10,
agent_downed: 11,
ally_rescued: 12,
grenade_thrown: 13,
medical_kit_used: 14,
signature_ability_deployed: 15,
skill_deployed: 16,
skill_hit: 17,
skill_multishot_triggered: 18,
agent_hit: 19,
health_damage_taken: 20,
healing_applied: 21,
extra_health_granted: 22,
tick: 23,
proximity_snapshot: 24,
movement_snapshot: 25
};
function intervalAppliesAt(
@@ -634,10 +686,16 @@ function mechanicSummary(source: ActiveMechanicEvaluation) {
};
}
export type MechanicTimelineOptions = {
scenarioInputs?: Record<string, number | boolean>;
skillDeploymentEvents?: MechanicEvent[];
};
export function buildWeaponMechanicTimeline(
weapon: any,
profile: LoadoutDraft['combatProfile'],
criticalChancePercent = 0
criticalChancePercent = 0,
options: MechanicTimelineOptions = {}
): MechanicEvent[] {
const encounterSeconds = Math.max(0, Number(profile.encounterSeconds));
const rpm = Math.max(1, Number(weapon?.rpm));
@@ -662,7 +720,11 @@ export function buildWeaponMechanicTimeline(
const shotInterval = 60 / rpm;
while (time <= encounterSeconds && events.length < 100_000) {
if (magazineRemaining <= 0) {
events.push({ kind: 'reload_started', at: time });
events.push({
kind: 'reload_started',
at: time,
values: { emptyMagazine: magazineRemaining === 0 }
});
time += reloadSeconds;
if (time <= encounterSeconds) {
events.push({ kind: 'reload_completed', at: time });
@@ -691,13 +753,21 @@ export function buildWeaponMechanicTimeline(
const critical = hit && criticalAccumulator >= 1;
if (critical) criticalAccumulator -= 1;
const targetId = `target-${Math.floor(time / targetInterval)}`;
const emptiedBeforeShot = magazine - magazineRemaining;
const emptiedAfterShot = emptiedBeforeShot + 1;
const magazineTwentyPercentBandsCrossed = [20, 40, 60, 80].filter((threshold) =>
emptiedBeforeShot * 100 < threshold * magazine
&& emptiedAfterShot * 100 >= threshold * magazine
).length;
events.push({
kind: 'shot_fired',
at: time,
targetId,
values: {
hit,
magazineRemaining,
magazinePercent: magazineRemaining / magazine * 100,
magazineTwentyPercentBandsCrossed,
distanceMeters: Number(profile.targetDistanceMeters)
}
});
@@ -722,24 +792,53 @@ export function buildWeaponMechanicTimeline(
if (!(perMinute > 0)) return;
const interval = 60 / perMinute;
let headshotKillAccumulator = 0;
let criticalKillAccumulator = 0;
for (let at = interval; at <= encounterSeconds; at += interval) {
if (kind === 'enemy_killed') {
headshotKillAccumulator += Math.max(0, Math.min(100, Number(profile.headshotKillRate))) / 100;
const headshot = headshotKillAccumulator >= 1;
if (headshot) headshotKillAccumulator -= 1;
criticalKillAccumulator += criticalChance;
const critical = criticalKillAccumulator >= 1;
if (critical) criticalKillAccumulator -= 1;
events.push({
kind,
at,
targetId: `target-${Math.floor(at / targetInterval)}`,
values: { headshot, source: 'weapon' }
values: {
headshot,
critical,
source: 'weapon',
behindCover: options.scenarioInputs?.['behind-cover'] === true,
distanceMeters: Number(profile.targetDistanceMeters)
}
});
}
else events.push({ kind, at });
else {
events.push(kind === 'health_damage_taken'
? {
kind,
at,
values: {
distanceMeters: Number(profile.targetDistanceMeters),
recentSkillDeploymentSeconds: Number(
options.scenarioInputs?.['recent-skill-deployment-seconds'] ?? encounterSeconds
)
}
}
: { kind, at });
if (kind === 'health_damage_taken') events.push({ kind: 'agent_hit', at });
}
}
};
intervalEvents('enemy_killed', Number(profile.killsPerMinute));
intervalEvents('health_damage_taken', Number(profile.damageTakenPerMinute));
intervalEvents('weapon_swapped', Number(profile.swapsPerMinute));
const movementState = options.scenarioInputs?.sprinting === true
? 'sprinting'
: options.scenarioInputs?.moving === true
? 'moving'
: 'immobile';
for (let at = 0; at <= encounterSeconds; at += 1) {
events.push({ kind: 'tick', at });
events.push({
@@ -748,12 +847,16 @@ export function buildWeaponMechanicTimeline(
values: {
distanceMeters: Number(profile.targetDistanceMeters),
targetDistanceMeters: Number(profile.targetDistanceMeters),
enemyCount: 1,
allyCount: 0
}
});
events.push({ kind: 'movement_snapshot', at, values: { moving: false, sprinting: false } });
extraHealthActive: Boolean(profile.extraHealthActive),
behindCover: options.scenarioInputs?.['behind-cover'] === true,
enemiesWithinRadius: Number(profile.targetDistanceMeters) <= 20 ? 1 : 0,
enemyCount: Number(profile.targetDistanceMeters) <= 20 ? 1 : 0,
allyCount: Number(options.scenarioInputs?.['allies-within-radius'] ?? 0)
}
});
events.push({ kind: 'movement_snapshot', at, values: { state: movementState } });
}
events.push(...(options.skillDeploymentEvents ?? []));
return events.sort((left, right) => left.at - right.at);
}
@@ -768,6 +871,147 @@ function effectAppliesToSkills(scope: string) {
return !['allies', 'equipped_weapon'].includes(scope);
}
function skillCalculationBindings(catalog: Catalog): SkillAttributeBinding[] {
const supportedStages = new Set([
'flat_damage',
'skill_damage',
'final_damage',
'critical_chance',
'critical_damage',
'multishot_chance',
'multishot_damage',
'cooldown_recovery',
'duration'
]);
return (catalog.calculation.skillProfiles ?? []).flatMap((profile: any) => {
const stage = profile.stage_key === 'engineering' ? 'skill_damage' : String(profile.stage_key);
return supportedStages.has(stage)
? [{
attributeId: String(profile.stat_attribute_id),
stage: stage as SkillAttributeBinding['stage'],
operator: profile.operator === 'multiply' ? 'multiply' as const : 'add' as const,
weight: Number(profile.weight ?? 1)
}]
: [];
});
}
function loadoutSkillAbilities(draft: LoadoutDraft, catalog: Catalog): SkillAbilityInput[] {
const focus = selectedFocus(draft, catalog);
return (focus?.abilities ?? []).map((ability: any): SkillAbilityInput => {
const attack = ability.attack;
const selected = draft.skillActivations[ability.id] ?? { mode: 'off', targets: 1 };
const activation = selected.mode === 'custom_per_minute'
? { mode: 'custom_per_minute' as const, usesPerMinute: Number(selected.usesPerMinute ?? 1) }
: selected.mode === 'on_cooldown'
? { mode: 'on_cooldown' as const }
: { mode: 'off' as const };
const chargePolicy = attack?.charge_policy === 'independent'
? { type: 'independent' as const, charges: Number(attack.charge_count) }
: attack?.charge_policy === 'full_set'
? { type: 'full_set' as const, charges: Number(attack.charge_count) }
: { type: 'none' as const };
return {
id: String(ability.id),
baseDamage: attack?.base_damage === undefined ? undefined : Number(attack.base_damage),
damageType: attack?.damage_type,
delivery: attack?.delivery === 'periodic'
? {
type: 'periodic' as const,
ticks: Math.max(1, Math.floor(Number(attack.damage_duration_seconds) / Number(attack.tick_interval_seconds))),
durationSeconds: Number(attack.damage_duration_seconds),
hitsPerTick: Number(attack.hits_per_activation ?? 1),
intervalSeconds: Number(attack.tick_interval_seconds),
firstTickDelaySeconds: Number(attack.tick_interval_seconds)
}
: { type: 'direct' as const, hits: Number(attack?.hits_per_activation ?? 1) },
castSeconds: Number(attack?.cast_seconds ?? 0),
cooldownSeconds: Number(ability.cooldown_seconds ?? 0),
targetCap: Number(attack?.target_cap ?? 1),
targetCount: Number(selected.targets ?? 1),
chargePolicy,
activation
};
});
}
export function buildSkillDeploymentEvents(
abilities: SkillAbilityInput[],
encounterSeconds: number,
rules: SkillCalculationRules = { revisionId: 'mechanic-timeline', bindings: [] },
attributes: Record<string, number> = {}
): MechanicEvent[] {
const input: SkillCalculationInput = {
rules,
abilities,
attributes,
assumptions: { encounterSeconds, targetCount: 1 }
};
return abilities.flatMap((ability) => {
if (
ability.activation.mode === 'off'
|| (
ability.activation.mode === 'on_cooldown'
&& Number(ability.castSeconds) <= 0
&& Number(ability.cooldownSeconds) <= 0
)
) return [];
return skillActivationTimes(ability, encounterSeconds, input, rules).map((at): MechanicEvent => ({
kind: 'skill_deployed',
at,
targetId: ability.id,
values: { abilityId: ability.id }
}));
}).sort((left, right) => left.at - right.at);
}
function loadoutSkillDeploymentEvents(
draft: LoadoutDraft,
catalog: Catalog,
summary: LoadoutSummary
) {
const bindings = skillCalculationBindings(catalog);
const boundIds = new Set(bindings.map((binding) => binding.attributeId));
const attributes = Object.fromEntries(
summary.stats.filter((stat) => boundIds.has(stat.id)).map((stat) => [stat.id, stat.value])
);
return buildSkillDeploymentEvents(
loadoutSkillAbilities(draft, catalog),
Math.max(1, Number(draft.combatProfile.encounterSeconds)),
{ revisionId: String(catalog.calculation.revision?.id ?? 'mechanic-timeline'), bindings },
attributes
);
}
export function skillModifierWindowsForMechanic(
source: ActiveMechanicEvaluation,
sourceAbilityId: string | null,
focusAbilityIds: ReadonlySet<string>,
allowedAttributeIds: ReadonlySet<string>
): SkillModifierWindow[] {
return modifierIntervals(source).flatMap((interval) => {
if (
!effectAppliesToSkills(interval.scope)
|| !allowedAttributeIds.has(interval.attributeId)
|| interval.end <= interval.start
) return [];
const abilityIds = interval.activationAbilityId
? (focusAbilityIds.has(interval.activationAbilityId) ? [interval.activationAbilityId] : [])
: interval.scope === 'assigned_ability'
? (sourceAbilityId && focusAbilityIds.has(sourceAbilityId) ? [sourceAbilityId] : [])
: undefined;
if ((interval.activationAbilityId || interval.scope === 'assigned_ability') && !abilityIds?.length) {
return [];
}
return [{
startSeconds: interval.start,
endSeconds: interval.end,
attributes: { [interval.attributeId]: interval.value },
abilityIds
}];
});
}
function skillModifierWindows(
draft: LoadoutDraft,
catalog: Catalog,
@@ -805,16 +1049,24 @@ function skillModifierWindows(
const primaryBindings = calculationBindings(catalog, primaryWeapon);
const baseAttributes = Object.fromEntries(summary.stats.map((stat) => [stat.id, stat.value]));
const primaryCriticalChance = weaponCriticalChance(baseAttributes, primaryBindings);
const focusAbilityIds = new Set((focus?.abilities ?? []).map((ability: any) => String(ability.id)));
const focusAbilityIds = new Set<string>(
(focus?.abilities ?? []).map((ability: any) => String(ability.id))
);
const windows: SkillModifierWindow[] = [];
const maxHealthId = catalog.attributes.find((item) => item.name === 'Health')?.id;
const maxHealth = summary.stats.find((stat) => stat.id === maxHealthId)?.value ?? 1;
const skillDeploymentEvents = loadoutSkillDeploymentEvents(draft, catalog, summary);
const evaluatedSources = sources.flatMap((source) => {
const state = draft.conditionalEffects[source.key] ?? { mode: 'off' };
const profile = { ...draft.combatProfile, ...(state.overrides ?? {}) };
const evaluation = evaluateConfiguredMechanic(
source.program!,
primaryWeapon ? buildWeaponMechanicTimeline(primaryWeapon, profile, primaryCriticalChance) : [],
(scenarioInputs) => primaryWeapon
? buildWeaponMechanicTimeline(primaryWeapon, profile, primaryCriticalChance, {
scenarioInputs,
skillDeploymentEvents
})
: skillDeploymentEvents,
state,
profile,
Number(primarySelection.damage ?? 0),
@@ -827,19 +1079,12 @@ function skillModifierWindows(
});
for (const source of evaluatedSources) {
for (const interval of modifierIntervals(source.evaluation)) {
if (!effectAppliesToSkills(interval.scope) || !allowedAttributeIds.has(interval.attributeId)) continue;
const abilityIds = interval.scope === 'assigned_ability'
? (source.abilityId && focusAbilityIds.has(source.abilityId) ? [source.abilityId] : [])
: undefined;
if (interval.scope === 'assigned_ability' && !abilityIds?.length) continue;
windows.push({
startSeconds: interval.start,
endSeconds: interval.end,
attributes: { [interval.attributeId]: interval.value },
abilityIds
});
}
windows.push(...skillModifierWindowsForMechanic(
source.evaluation,
source.abilityId,
focusAbilityIds,
allowedAttributeIds
));
}
return windows;
@@ -872,6 +1117,7 @@ export function evaluateLoadoutCombat(
program: conditionalProgram(effect.rule, 'base')
}))
].filter((source) => source.program);
const skillDeploymentEvents = loadoutSkillDeploymentEvents(draft, catalog, summary);
const required = (weaponIndex: number) => {
const missing: string[] = [];
if (!catalog.calculation.revision) missing.push('An active ISAC calculation revision');
@@ -911,13 +1157,14 @@ export function evaluateLoadoutCombat(
) => conditionalSources.flatMap((source) => {
const state = draft.conditionalEffects[source.key] ?? { mode: 'off' };
const profile = { ...draft.combatProfile, ...(state.overrides ?? {}) };
const evaluation = evaluateConfiguredMechanic(
source.program!,
buildWeaponMechanicTimeline(
timelineWeapon,
profile,
weaponCriticalChance(criticalAttributes, bindings)
),
const evaluation = evaluateConfiguredMechanic(
source.program!,
(scenarioInputs) => buildWeaponMechanicTimeline(
timelineWeapon,
profile,
weaponCriticalChance(criticalAttributes, bindings),
{ scenarioInputs, skillDeploymentEvents }
),
state,
profile,
Number(selection.damage),
@@ -1070,70 +1317,13 @@ export function evaluateLoadoutSkillCombat(
catalog: Catalog,
summary = summarizeLoadout(draft, catalog)
): SkillCalculation {
const focus = selectedFocus(draft, catalog);
const revisionId = String(catalog.calculation.revision?.id ?? '');
const supportedStages = new Set([
'flat_damage',
'skill_damage',
'final_damage',
'critical_chance',
'critical_damage',
'multishot_chance',
'multishot_damage',
'cooldown_recovery',
'duration'
]);
const bindings: SkillAttributeBinding[] = (catalog.calculation.skillProfiles ?? [])
.flatMap((profile: any) => {
const stage = profile.stage_key === 'engineering' ? 'skill_damage' : String(profile.stage_key);
return supportedStages.has(stage)
? [{
attributeId: String(profile.stat_attribute_id),
stage: stage as SkillAttributeBinding['stage'],
operator: profile.operator === 'multiply' ? 'multiply' as const : 'add' as const,
weight: Number(profile.weight ?? 1)
}]
: [];
});
const bindings = skillCalculationBindings(catalog);
const boundIds = new Set(bindings.map((binding) => binding.attributeId));
const attributes = Object.fromEntries(
summary.stats.filter((stat) => boundIds.has(stat.id)).map((stat) => [stat.id, stat.value])
);
const abilities = (focus?.abilities ?? []).map((ability: any) => {
const attack = ability.attack;
const selected = draft.skillActivations[ability.id] ?? { mode: 'off', targets: 1 };
const activation = selected.mode === 'custom_per_minute'
? { mode: 'custom_per_minute' as const, usesPerMinute: Number(selected.usesPerMinute ?? 1) }
: selected.mode === 'on_cooldown'
? { mode: 'on_cooldown' as const }
: { mode: 'off' as const };
const chargePolicy = attack?.charge_policy === 'independent'
? { type: 'independent' as const, charges: Number(attack.charge_count) }
: attack?.charge_policy === 'full_set'
? { type: 'full_set' as const, charges: Number(attack.charge_count) }
: { type: 'none' as const };
return {
id: String(ability.id),
baseDamage: attack?.base_damage === undefined ? undefined : Number(attack.base_damage),
damageType: attack?.damage_type,
delivery: attack?.delivery === 'periodic'
? {
type: 'periodic' as const,
ticks: Math.max(1, Math.floor(Number(attack.damage_duration_seconds) / Number(attack.tick_interval_seconds))),
durationSeconds: Number(attack.damage_duration_seconds),
hitsPerTick: Number(attack.hits_per_activation ?? 1),
intervalSeconds: Number(attack.tick_interval_seconds),
firstTickDelaySeconds: Number(attack.tick_interval_seconds)
}
: { type: 'direct' as const, hits: Number(attack?.hits_per_activation ?? 1) },
castSeconds: Number(attack?.cast_seconds ?? 0),
cooldownSeconds: Number(ability.cooldown_seconds ?? 0),
targetCap: Number(attack?.target_cap ?? 1),
targetCount: Number(selected.targets ?? 1),
chargePolicy,
activation
};
});
const abilities = loadoutSkillAbilities(draft, catalog);
return calculateSkillDps({
rules: { revisionId, bindings },
abilities,
@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest';
import {
currentHealthPercentBandPredicate,
eventFieldComparisonPredicate,
parseConditionalMechanicDraft,
readCurrentHealthPercentBand,
readEventFieldComparison,
readStatePoolThreshold,
statePoolThresholdPredicate,
type ConditionalMechanicDraftV1
} from './mechanic-editor.js';
@@ -54,6 +60,116 @@ const draft = (): ConditionalMechanicDraftV1 => ({
});
describe('conditional mechanic authoring payload', () => {
it('resolves and validates variant-specific pool expiration durations', () => {
const value = draft();
value.pools[0] = {
...value.pools[0],
expiresAfterSeconds: undefined,
expiresAfterSecondsVariants: { I: 12, II: 18 }
};
const result = parseConditionalMechanicDraft(value, { variantKeys: ['I', 'II'] });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.programs.I.pools?.[0].expiresAfterSeconds).toBe(12);
expect(result.value.programs.II.pools?.[0].expiresAfterSeconds).toBe(18);
value.pools[0].expiresAfterSecondsVariants = { I: 12 };
const invalid = parseConditionalMechanicDraft(value, { variantKeys: ['I', 'II'] });
expect(invalid.ok).toBe(false);
if (!invalid.ok) {
expect(invalid.errors).toContain(
'State pool 1 variant expiration durations must define one positive number for each variant.'
);
}
});
it('round-trips strict manual current Health percentage band editor state', () => {
const predicate = currentHealthPercentBandPredicate({ minimum: 25, maximum: 50 });
expect(readCurrentHealthPercentBand(predicate)).toEqual({ minimum: 25, maximum: 50 });
expect(predicate).toEqual({
kind: 'all',
predicates: [{
kind: 'compare',
left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' },
operator: 'gte',
right: 25
}, {
kind: 'compare',
left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' },
operator: 'lt',
right: 50
}]
});
expect(readCurrentHealthPercentBand({
kind: 'compare',
left: { source: 'event', path: 'values.healthPercent' },
operator: 'lt',
right: 50
})).toBeNull();
});
it('round-trips editable event scalar comparisons without claiming advanced predicates', () => {
for (const value of ['moving', 4, true]) {
const comparison = { path: 'values.state', operator: 'eq' as const, value };
const predicate = eventFieldComparisonPredicate(comparison);
expect(readEventFieldComparison(predicate)).toEqual(comparison);
}
expect(readEventFieldComparison({
kind: 'compare',
left: { source: 'event', path: 'values.state' },
operator: 'eq',
right: { source: 'event', path: 'values.previousState' }
})).toBeNull();
expect(readEventFieldComparison({
kind: 'all',
predicates: []
})).toBeNull();
});
it('round-trips state-pool thresholds and preserves unsupported targeted pools as advanced', () => {
const comparison = { pool: 'stacks', operator: 'gte' as const, value: 10 };
const predicate = statePoolThresholdPredicate(comparison);
expect(readStatePoolThreshold(predicate)).toEqual(comparison);
expect(readStatePoolThreshold({
kind: 'compare',
left: { source: 'pool', pool: 'stacks', entity: { kind: 'event_target' } },
operator: 'gt',
right: 0
})).toBeNull();
});
it('parses movement-state and stack-threshold authoring predicates unchanged', () => {
const value = draft();
value.clauses[0].event = 'movement_snapshot';
value.clauses[0].if = eventFieldComparisonPredicate({
path: 'values.state',
operator: 'eq',
value: 'sprinting'
});
value.clauses.push({
...structuredClone(value.clauses[0]),
key: 'consume-stacks',
event: 'shot_fired',
if: statePoolThresholdPredicate({
pool: 'effect',
operator: 'gt',
value: 0
})
});
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.map((clause) => clause.if)).toEqual(
value.clauses.map((clause) => clause.if)
);
expect(result.value.programs.I.clauses.map((clause) => clause.if)).toEqual(
value.clauses.map((clause) => clause.if)
);
});
it('accepts an empty captured-only program for deferred modeling', () => {
const partial: ConditionalMechanicDraftV1 = {
version: 1,
@@ -427,4 +543,51 @@ describe('conditional mechanic authoring payload', () => {
errors: ['The conditional mechanic contains malformed JSON.']
});
});
it('rejects invalid pre-event timing for current Health percentage references', () => {
const invalid = draft();
invalid.clauses[0].if = {
kind: 'compare',
left: { source: 'combat', path: 'currentHealthPercent', timing: 'pre_event' },
operator: 'lt',
right: 50
};
const result = parseConditionalMechanicDraft(invalid, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage'])
});
expect(result.ok).toBe(false);
});
it('accepts compatible radius targeting and rejects incompatible scopes', () => {
const valid = draft();
valid.clauses[0].outcomes[0].scope = 'allies';
valid.clauses[0].outcomes[0].targeting = {
kind: 'within_radius',
population: 'allies',
radiusMeters: 40
};
const parsed = parseConditionalMechanicDraft(valid, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage'])
});
expect(parsed.ok).toBe(true);
if (parsed.ok) {
expect(parsed.value.draft.clauses[0].outcomes[0].targeting).toEqual(
valid.clauses[0].outcomes[0].targeting
);
}
valid.clauses[0].outcomes[0].scope = 'self';
const incompatible = parseConditionalMechanicDraft(valid, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage'])
});
expect(incompatible.ok).toBe(false);
if (!incompatible.ok) {
expect(incompatible.errors).toContain(
'Clause 1 outcome 1 ally radius targeting requires allies or self-and-allies scope.'
);
}
});
});
+192 -6
View File
@@ -14,6 +14,7 @@ import {
type PoolDefinition,
type Predicate,
type Reducer,
type Scalar,
type ScenarioInputDefinition,
type StateScope,
type ValueReference
@@ -32,6 +33,7 @@ export type MechanicStateKind =
export type ConditionalMechanicPoolDraft = PoolDefinition & {
stateKind: MechanicStateKind;
expiresAfterSecondsVariants?: Record<string, number>;
};
export type ConditionalMechanicVariantValue = {
@@ -77,6 +79,138 @@ export type ConditionalMechanicDraftResult =
| { ok: true; value: ParsedConditionalMechanicDraft }
| { ok: false; errors: string[] };
export type CurrentHealthPercentBand = {
minimum?: number;
maximum: number;
};
const currentHealthPercentReference = {
source: 'combat',
path: 'currentHealthPercent',
timing: 'current'
} as const;
export function currentHealthPercentBandPredicate(
band: CurrentHealthPercentBand
): Predicate {
const maximum: Predicate = {
kind: 'compare',
left: currentHealthPercentReference,
operator: 'lt',
right: band.maximum
};
if (band.minimum === undefined) return maximum;
return {
kind: 'all',
predicates: [{
kind: 'compare',
left: currentHealthPercentReference,
operator: 'gte',
right: band.minimum
}, maximum]
};
}
export function readCurrentHealthPercentBand(
predicate: Predicate | undefined
): CurrentHealthPercentBand | null {
const predicates = predicate?.kind === 'all' ? predicate.predicates : predicate ? [predicate] : [];
let minimum: number | undefined;
let maximum: number | undefined;
for (const item of predicates) {
if (
item.kind !== 'compare'
|| typeof item.left !== 'object'
|| item.left === null
|| item.left.source !== 'combat'
|| item.left.path !== 'currentHealthPercent'
|| item.left.timing !== 'current'
|| typeof item.right !== 'number'
) return null;
if (item.operator === 'gte') minimum = item.right;
else if (item.operator === 'lt') maximum = item.right;
else return null;
}
return maximum === undefined ? null : { ...(minimum === undefined ? {} : { minimum }), maximum };
}
export type PredicateComparisonOperator = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte';
export type EditablePredicateScalar = Exclude<Scalar, null>;
export type EventFieldComparison = {
path: string;
operator: PredicateComparisonOperator;
value: EditablePredicateScalar;
};
export function eventFieldComparisonPredicate(
comparison: EventFieldComparison
): Predicate {
return {
kind: 'compare',
left: { source: 'event', path: comparison.path },
operator: comparison.operator,
right: comparison.value
};
}
export function readEventFieldComparison(
predicate: Predicate | undefined
): EventFieldComparison | null {
if (
predicate?.kind !== 'compare'
|| typeof predicate.left !== 'object'
|| predicate.left === null
|| predicate.left.source !== 'event'
|| !predicate.left.path.trim()
|| (typeof predicate.right !== 'string'
&& typeof predicate.right !== 'number'
&& typeof predicate.right !== 'boolean')
) return null;
return {
path: predicate.left.path,
operator: predicate.operator,
value: predicate.right
};
}
export type StatePoolThresholdComparison = {
pool: string;
operator: PredicateComparisonOperator;
value: number;
};
export function statePoolThresholdPredicate(
comparison: StatePoolThresholdComparison
): Predicate {
return {
kind: 'compare',
left: { source: 'pool', pool: comparison.pool },
operator: comparison.operator,
right: comparison.value
};
}
export function readStatePoolThreshold(
predicate: Predicate | undefined
): StatePoolThresholdComparison | null {
if (
predicate?.kind !== 'compare'
|| typeof predicate.left !== 'object'
|| predicate.left === null
|| predicate.left.source !== 'pool'
|| !predicate.left.pool.trim()
|| predicate.left.entity !== undefined
|| typeof predicate.right !== 'number'
|| !Number.isFinite(predicate.right)
) return null;
return {
pool: predicate.left.pool,
operator: predicate.operator,
value: predicate.right
};
}
const object = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const finite = (value: unknown): value is number =>
@@ -97,13 +231,15 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = {
'perStack',
'minStacks',
'maxStacks',
'targeting',
'descriptionSlot'
],
phase: ['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active'],
kind: ['attribute_modifier', 'direct_damage', 'restoration', 'resource_cost', 'marker'],
kind: ['attribute_modifier', 'direct_damage', 'restoration', 'resource_cost', 'weapon_reload', 'marker'],
scope: [
'self',
'equipped_weapon',
'inactive_weapon',
'target',
'allies',
'self_and_allies',
@@ -111,7 +247,7 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = {
'all_skills'
],
basis: ['flat', 'max_health_percent', 'current_health_percent', 'weapon_base_percent'],
resource: ['health', 'armor', 'ammo'],
resource: ['health', 'armor', 'ammo', 'signature_ability_charge'],
delivery: ['instant', 'continuous'],
deliverySemantics: {
instant: 'The full resolved value applies at activation.',
@@ -175,7 +311,8 @@ function isReference(value: unknown): value is ValueReference {
case 'combat':
return typeof value.path === 'string'
&& Boolean(value.path.trim())
&& (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current');
&& (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current')
&& !(value.path === 'currentHealthPercent' && value.timing === 'pre_event');
case 'equipment':
return typeof value.path === 'string' && Boolean(value.path.trim());
case 'attribute':
@@ -222,6 +359,7 @@ function isExpression(value: unknown): value is NumericExpression {
if (finite(value)) return true;
if (!object(value)) return false;
if ('ref' in value) return isReference(value.ref);
if (value.op === 'floor') return isExpression(value.value);
if (value.op === 'clamp') {
return isExpression(value.value) && isExpression(value.min) && isExpression(value.max);
}
@@ -333,7 +471,7 @@ function parseScenarioInputs(value: unknown, errors: string[]) {
return inputs;
}
function parsePools(value: unknown, errors: string[]) {
function parsePools(value: unknown, variantKeys: readonly string[], errors: string[]) {
if (!Array.isArray(value)) {
errors.push('State pools must be an array.');
return [] as ConditionalMechanicPoolDraft[];
@@ -362,6 +500,26 @@ function parsePools(value: unknown, errors: string[]) {
errors.push(`${path} ${property} must be a finite number.`);
}
}
if (candidate.expiresAfterSeconds !== undefined && candidate.expiresAfterSecondsVariants !== undefined) {
errors.push(`${path} cannot define both scalar and variant expiration durations.`);
}
if (candidate.expiresAfterSecondsVariants !== undefined) {
if (!object(candidate.expiresAfterSecondsVariants)) {
errors.push(`${path} variant expiration durations must be an object.`);
} else {
const variantDurations = candidate.expiresAfterSecondsVariants;
const keys = Object.keys(variantDurations);
if (
keys.length !== variantKeys.length
|| keys.some((variant) => !variantKeys.includes(variant))
|| variantKeys.some((variant) =>
!finite(variantDurations[variant])
|| Number(variantDurations[variant]) <= 0)
) {
errors.push(`${path} variant expiration durations must define one positive number for each variant.`);
}
}
}
pools.push(candidate as ConditionalMechanicPoolDraft);
}
return pools;
@@ -439,6 +597,25 @@ function parseOutcome(
) {
errors.push(`${path} description slot must be a positive integer.`);
}
if (value.targeting !== undefined) {
if (
!object(value.targeting)
|| value.targeting.kind !== 'within_radius'
|| !['allies', 'enemies'].includes(String(value.targeting.population))
|| !finite(value.targeting.radiusMeters)
|| value.targeting.radiusMeters <= 0
) {
errors.push(`${path} has invalid radius targeting.`);
} else if (
value.targeting.population === 'allies'
&& value.scope !== 'allies'
&& value.scope !== 'self_and_allies'
) {
errors.push(`${path} ally radius targeting requires allies or self-and-allies scope.`);
} else if (value.targeting.population === 'enemies' && value.scope !== 'target') {
errors.push(`${path} enemy radius targeting requires target scope.`);
}
}
if (!object(value.variants)) {
errors.push(`${path} variants must be an object.`);
return null;
@@ -555,7 +732,16 @@ function programsForDraft(
id: `${options.programId ?? 'mechanic'}:${variant}`,
name: options.programName?.trim() || 'Conditional Mechanic',
scenarioInputs: draft.scenarioInputs,
pools: draft.pools.map(({ stateKind: _stateKind, ...pool }) => pool),
pools: draft.pools.map(({
stateKind: _stateKind,
expiresAfterSecondsVariants,
...pool
}) => ({
...pool,
...(expiresAfterSecondsVariants
? { expiresAfterSeconds: expiresAfterSecondsVariants[variant] }
: {})
})),
clauses: draft.clauses.map((clause): MechanicClause => ({
id: clause.key,
...(clause.label ? { label: clause.label } : {}),
@@ -607,7 +793,7 @@ export function parseConditionalMechanicDraft(
errors.push('The conditional mechanic has invalid simulation readiness.');
}
const scenarioInputs = parseScenarioInputs(parsed.scenarioInputs, errors);
const pools = parsePools(parsed.pools, errors);
const pools = parsePools(parsed.pools, options.variantKeys, errors);
const clauses = parseClauses(parsed.clauses, options.variantKeys, options, errors);
const draft: ConditionalMechanicDraftV1 = {
version: CONDITIONAL_MECHANIC_DRAFT_VERSION,
@@ -78,6 +78,7 @@ const outcome = (
value: 3,
value_expression_json: null,
value_basis: 'flat',
targeting_json: null,
value_scaling: 'per_state_unit',
unit: '%',
damage_basis: null,
@@ -152,6 +153,33 @@ describe('conditional-effect persistence adapter', () => {
expect(result.program?.clauses[0].outcomes?.[0]).not.toHaveProperty('durationSeconds');
});
it('preserves radius targeting metadata on an outcome', () => {
const result = adaptConditionalEffectProgram({
rule: rule(),
statePools: [pool()],
clauses: [clause({ trigger_id: null, event_kind: 'enemy_killed' })],
outcomes: [outcome({
effect_scope: 'allies',
targeting_json: JSON.stringify({
kind: 'within_radius',
population: 'allies',
radiusMeters: 40
})
})],
triggers: []
}, { name: 'Slaughter Healing', variantKey: 'I' });
expect(result.errors).toEqual([]);
expect(result.program?.clauses[0].outcomes?.[0]).toMatchObject({
scope: 'allies',
targeting: {
kind: 'within_radius',
population: 'allies',
radiusMeters: 40
}
});
});
it('loads continuous restoration delivery metadata', () => {
const result = adaptConditionalEffectProgram({
rule: rule(),
@@ -589,4 +617,30 @@ describe('conditional-effect persistence adapter', () => {
'Clause 1 mutations contains malformed JSON.'
]));
});
it('preserves a persisted current Health percentage predicate', () => {
const condition = {
kind: 'compare',
left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' },
operator: 'lt',
right: 25
};
const result = adaptConditionalEffectProgram({
rule: rule(),
statePools: [],
clauses: [clause({
trigger_id: null,
event_kind: 'tick',
parameters_json: '{}',
conditions_json: JSON.stringify(condition),
state_pool_id: null,
mutations_json: '[]'
})],
outcomes: [],
triggers: []
}, { name: 'Spirited', variantKey: 'I' });
expect(result.status).toBe('READY');
expect(result.program?.clauses[0].if).toEqual(condition);
});
});
+43 -15
View File
@@ -84,6 +84,7 @@ export type ConditionalEffectOutcomeRow = {
combat_timing?: DatabaseValue;
delivery_mode?: DatabaseValue;
delivery_duration_seconds?: DatabaseValue;
targeting_json?: DatabaseValue;
value_scaling: DatabaseValue;
unit: DatabaseValue;
damage_basis: DatabaseValue;
@@ -192,7 +193,8 @@ function isValueReference(value: unknown) {
if (value.source === 'combat') {
return typeof value.path === 'string'
&& Boolean(value.path.trim())
&& (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current');
&& (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current')
&& !(value.path === 'currentHealthPercent' && value.timing === 'pre_event');
}
if (value.source === 'attribute') {
return typeof value.attributeId === 'string' && Boolean(value.attributeId.trim());
@@ -211,6 +213,7 @@ function isNumericExpression(value: unknown): value is NumericExpression {
if (finite(value)) return true;
if (!object(value)) return false;
if ('ref' in value) return isValueReference(value.ref);
if (value.op === 'floor') return isNumericExpression(value.value);
if (value.op === 'clamp') {
return isNumericExpression(value.value)
&& isNumericExpression(value.min)
@@ -441,6 +444,7 @@ function parsePool(
row: ConditionalEffectStatePoolRow,
ruleId: string,
index: number,
selectedVariant: string,
errors: string[]
): { id: string; definition: PoolDefinition } | null {
const path = `State pool ${index + 1}`;
@@ -460,6 +464,14 @@ function parsePool(
const duration = number(row.duration_seconds);
const parameters = parseJson(row.parameters_json, `${path} parameters`, errors);
if (parameters.ok && !object(parameters.value)) errors.push(`${path} parameters must be a JSON object.`);
const durationVariants = parameters.ok && object(parameters.value)
&& object(parameters.value.expiresAfterSecondsVariants)
? parameters.value.expiresAfterSecondsVariants
: null;
const variantDuration = durationVariants ? number(durationVariants[selectedVariant] as DatabaseValue) : null;
if (durationVariants && variantDuration === null) {
errors.push(`${path} has no valid expiration duration for variant "${selectedVariant}".`);
}
return {
id,
definition: {
@@ -470,7 +482,9 @@ function parsePool(
...(minimum === null ? {} : { min: minimum }),
max: maximum,
...(bestCase === null ? {} : { bestCaseValue: bestCase }),
...(duration === null ? {} : { expiresAfterSeconds: duration })
...(variantDuration !== null
? { expiresAfterSeconds: variantDuration }
: duration === null ? {} : { expiresAfterSeconds: duration })
}
};
}
@@ -478,10 +492,14 @@ function parsePool(
function outcomeKind(value: string): MechanicOutcome['kind'] | null {
if (value === 'active_modifier') return 'attribute_modifier';
if (value === 'direct_damage') return 'direct_damage';
if (value === 'restoration' || ['restore_health', 'restore_armor', 'restore_ammo'].includes(value)) {
if (
value === 'restoration'
|| ['restore_health', 'restore_armor', 'restore_ammo', 'restore_signature_ability_charge'].includes(value)
) {
return 'restoration';
}
if (value === 'resource_cost') return 'resource_cost';
if (value === 'weapon_reload') return 'weapon_reload';
if (value === 'marker') return 'marker';
return null;
}
@@ -489,6 +507,7 @@ function outcomeKind(value: string): MechanicOutcome['kind'] | null {
function outcomeScope(value: string): MechanicOutcome['scope'] | null {
if (value === 'self') return 'self';
if (value === 'equipped_weapon') return 'equipped_weapon';
if (value === 'inactive_weapon') return 'inactive_weapon';
if (value === 'target') return 'target';
if (value === 'allies') return 'allies';
if (value === 'self_and_allies') return 'self_and_allies';
@@ -547,15 +566,17 @@ function parseOutcome(
? 'armor'
: text(row.application_kind) === 'restore_ammo'
? 'ammo'
: text(row.application_kind) === 'restore_signature_ability_charge'
? 'signature_ability_charge'
: text(row.application_kind) === 'resource_cost'
? 'health'
: null;
const resource = nullableText(row.resource_kind) ?? legacyResource;
if (
(kind === 'restoration' || kind === 'resource_cost')
&& !['health', 'armor', 'ammo'].includes(String(resource))
&& !['health', 'armor', 'ammo', 'signature_ability_charge'].includes(String(resource))
) {
errors.push(`${path} requires an explicit health, armor, or ammo resource.`);
errors.push(`${path} requires an explicit supported resource.`);
}
const combatTiming = nullableText(row.combat_timing);
if (combatTiming && combatTiming !== 'pre_event' && combatTiming !== 'current') {
@@ -572,6 +593,13 @@ function parseOutcome(
if (delivery === 'instant' && durationSeconds !== null) {
errors.push(`${path} instant delivery cannot set a duration.`);
}
let targeting: MechanicOutcome['targeting'] | undefined;
if (row.targeting_json !== null && row.targeting_json !== undefined && row.targeting_json !== '') {
const parsed = parseJson(row.targeting_json, `${path} targeting`, errors);
if (parsed.ok && typeof parsed.value === 'object' && parsed.value !== null) {
targeting = parsed.value as MechanicOutcome['targeting'];
}
}
return {
clauseId: nullableText(row.clause_id),
outcome: {
@@ -591,6 +619,7 @@ function parseOutcome(
...(delivery === 'continuous' && durationSeconds !== null
? { delivery, durationSeconds }
: {}),
...(targeting ? { targeting } : {}),
...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id)
? { attributeId: text(row.stat_attribute_id) }
: {}),
@@ -627,16 +656,6 @@ export function adaptConditionalEffectProgram(
}
const scenarioInputs = parseScenarioInputs(input.rule.scenario_inputs_json, errors);
const parsedPools = input.statePools
.slice()
.sort((left, right) => (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0))
.map((row, index) => parsePool(row, ruleId, index, errors))
.filter((value): value is NonNullable<typeof value> => value !== null);
const poolKeys = new Map(parsedPools.map((pool) => [pool.id, pool.definition.key]));
const triggerMap = new Map(
input.triggers.map((trigger) => [text(trigger.id), trigger])
);
const variantKeys = [...new Set(input.outcomes.map((row) => text(row.variant_key)).filter(Boolean))];
let selectedVariant = options.variantKey?.trim() || '';
if (!selectedVariant) {
@@ -648,6 +667,15 @@ export function adaptConditionalEffectProgram(
} else if (variantKeys.length && !variantKeys.includes(selectedVariant)) {
errors.push(`Variant "${selectedVariant}" does not exist for this rule.`);
}
const parsedPools = input.statePools
.slice()
.sort((left, right) => (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0))
.map((row, index) => parsePool(row, ruleId, index, selectedVariant, errors))
.filter((value): value is NonNullable<typeof value> => value !== null);
const poolKeys = new Map(parsedPools.map((pool) => [pool.id, pool.definition.key]));
const triggerMap = new Map(
input.triggers.map((trigger) => [text(trigger.id), trigger])
);
const selectedOutcomes = input.outcomes
.filter((row) => text(row.variant_key) === selectedVariant)
@@ -142,10 +142,17 @@ describe('mechanic recipe registry', () => {
expect(health.clauses[1].if).toMatchObject({
kind: 'all',
predicates: expect.arrayContaining([
{ kind: 'compare', left: { source: 'event', path: 'values.healthPercent' }, operator: 'lte', right: 50 },
{ kind: 'compare', left: { source: 'event', path: 'values.healthPercent' }, operator: 'gt', right: 25 }
{ kind: 'compare', left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, operator: 'lt', right: 50 },
{ kind: 'compare', left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, operator: 'gte', right: 25 }
])
});
expect(health.clauses.every((clause) => clause.when === 'tick')).toBe(true);
expect(health.clauses[2].if).toEqual({
kind: 'compare',
left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' },
operator: 'gte',
right: 50
});
expect(seedFor('target_hit_within_distance').clauses[0]).toMatchObject({
when: 'weapon_hit',
+14 -9
View File
@@ -110,6 +110,11 @@ function firingLifecycleSeed(activePool: string, includeDuration?: number): Mech
function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed {
const sortedBands = [...params.bands].sort((left, right) => left.thresholdPercent - right.thresholdPercent);
const currentHealthPercent: ValueReference = {
source: 'combat',
path: 'currentHealthPercent',
timing: 'current'
};
const pools: PoolDefinition[] = sortedBands.map((band, index) => ({
key: `health-band-${index + 1}`,
label: `Health band: ${band.outcomeKey}`,
@@ -122,22 +127,22 @@ function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed {
const lowerBound = sortedBands[index - 1]?.thresholdPercent;
const predicates: Predicate[] = [{
kind: 'compare',
left: eventValue('healthPercent'),
operator: 'lte',
left: currentHealthPercent,
operator: 'lt',
right: band.thresholdPercent
}];
if (lowerBound !== undefined) {
predicates.push({
kind: 'compare',
left: eventValue('healthPercent'),
operator: 'gt',
left: currentHealthPercent,
operator: 'gte',
right: lowerBound
});
}
return {
id: `select-health-band-${index + 1}`,
label: `Select ${band.outcomeKey} at ${band.thresholdPercent}% Health`,
when: 'health_damage_taken',
label: `Select ${band.outcomeKey} below ${band.thresholdPercent}% Health`,
when: 'tick',
if: { kind: 'all', predicates },
reducer: { kind: 'none' },
mutations: pools.map((pool, poolIndex) =>
@@ -150,11 +155,11 @@ function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed {
clauses.push({
id: 'clear-health-bands',
label: 'Clear health bands above the highest threshold',
when: 'health_damage_taken',
when: 'tick',
if: {
kind: 'compare',
left: eventValue('healthPercent'),
operator: 'gt',
left: currentHealthPercent,
operator: 'gte',
right: sortedBands.at(-1)?.thresholdPercent ?? 0
},
reducer: { kind: 'none' },
+632 -3
View File
@@ -5,7 +5,9 @@ import {
formatMechanicProgram,
validateMechanicProgram,
type MechanicEvent,
type MechanicProgram
type MechanicEventKind,
type MechanicProgram,
type NumericExpression
} from './mechanic.js';
const custom = (encounterSeconds = 30, inputs: Record<string, number | boolean> = {}) => ({
@@ -17,14 +19,153 @@ const custom = (encounterSeconds = 30, inputs: Record<string, number | boolean>
describe('structured mechanic runtime', () => {
it('advertises the authoritative weapon-hit target and headshot fields', () => {
expect(MECHANIC_EVENT_REGISTRY.weapon_hit).toMatchObject({
description: expect.stringContaining('headshot boolean'),
description: expect.stringContaining('previous target'),
fields: [
{ path: 'targetId', label: 'Target ID', type: 'string' },
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' }
{ path: 'values.previousTargetId', label: 'Previous target ID', type: 'string' },
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' },
{ path: 'values.critical', label: 'Critical hit', type: 'boolean' },
{ path: 'values.behindCover', label: 'Agent behind cover', type: 'boolean' },
{ path: 'values.distanceMeters', label: 'Target distance', type: 'number' }
]
});
});
it('advertises imported support and equipment-use trigger events', () => {
expect(MECHANIC_EVENT_REGISTRY.ally_downed).toMatchObject({
key: 'ally_downed',
label: 'Ally downed'
});
expect(MECHANIC_EVENT_REGISTRY.agent_downed).toMatchObject({
key: 'agent_downed',
label: 'Agent downed'
});
expect(MECHANIC_EVENT_REGISTRY.ally_rescued).toMatchObject({
key: 'ally_rescued',
label: 'Ally rescued'
});
expect(MECHANIC_EVENT_REGISTRY.grenade_thrown).toMatchObject({
key: 'grenade_thrown',
label: 'Grenade thrown'
});
expect(MECHANIC_EVENT_REGISTRY.medical_kit_used).toMatchObject({
key: 'medical_kit_used',
label: 'Medical Kit used'
});
expect(MECHANIC_EVENT_REGISTRY.signature_ability_deployed).toMatchObject({
key: 'signature_ability_deployed',
label: 'Signature Ability deployed'
});
expect(MECHANIC_EVENT_REGISTRY.skill_multishot_triggered).toMatchObject({
key: 'skill_multishot_triggered',
label: 'Skill Multi-Shot triggered'
});
});
it('executes imported talent trigger families as first-class events', () => {
const eventKinds: MechanicEventKind[] = [
'ally_downed',
'agent_downed',
'ally_rescued',
'grenade_thrown',
'medical_kit_used',
'signature_ability_deployed',
'skill_multishot_triggered'
];
const program: MechanicProgram = {
id: 'imported-talent-events',
name: 'Imported talent events',
clauses: eventKinds.map((when) => ({
id: when,
when,
reducer: { kind: 'none' },
mutations: [{ kind: 'none' }],
outcomes: [{
id: `${when}-marker`,
label: `${when} marker`,
phase: 'after_event',
kind: 'marker',
scope: 'self',
basis: 'flat',
value: 1
}]
}))
};
expect(validateMechanicProgram(program)).toMatchObject({ ok: true });
const events: MechanicEvent[] = eventKinds.map((kind, index) => ({ kind, at: index + 1 }));
expect(evaluateMechanicProgram(program, events, custom()).outcomes.map(
(outcome) => outcome.outcomeId
)).toEqual(eventKinds.map((kind) => `${kind}-marker`));
});
it('derives the previous weapon-hit target for target-switch mechanics', () => {
const program: MechanicProgram = {
id: 'target-switch-stacks',
name: 'Target Switch Stacks',
pools: [{
key: 'stacks',
label: 'Stacks',
scope: 'agent',
initial: 0,
min: 0,
max: 100,
expiresAfterSeconds: 5
}],
clauses: [
{
id: 'halve-on-switch',
when: 'weapon_hit',
if: {
kind: 'all',
predicates: [
{ kind: 'exists', value: { source: 'event', path: 'values.previousTargetId' } },
{
kind: 'compare',
left: { source: 'event', path: 'values.previousTargetId' },
operator: 'ne',
right: { source: 'event', path: 'targetId' }
}
]
},
reducer: { kind: 'none' },
mutations: [{
kind: 'set',
pool: 'stacks',
value: {
op: 'multiply',
left: { ref: { source: 'pool', pool: 'stacks' } },
right: 0.5
}
}]
},
{
id: 'build-stacks',
when: 'weapon_hit',
if: { kind: 'exists', value: { source: 'event', path: 'targetId' } },
reducer: { kind: 'none' },
mutations: [{ kind: 'add', pool: 'stacks', value: 1 }]
}
]
};
const result = evaluateMechanicProgram(program, [
{ kind: 'weapon_hit', at: 0, targetId: 'alpha' },
{ kind: 'weapon_hit', at: 1, targetId: 'alpha' },
{ kind: 'weapon_hit', at: 2, targetId: 'bravo' }
], custom(3));
expect(result.status).toBe('OK');
expect(result.finalState).toContainEqual(expect.objectContaining({
pool: 'stacks',
value: 2
}));
expect(result.emissions.map((emission) => emission.clauseId)).toEqual([
'build-stacks',
'build-stacks',
'halve-on-switch',
'build-stacks'
]);
});
it('models Superior Firepower as same-target sliding hits and one shared expiring stack pool', () => {
const superiorFirepower: MechanicProgram = {
id: 'superior-firepower',
@@ -1077,6 +1218,242 @@ describe('structured mechanic runtime', () => {
});
describe('mechanic validation and pseudo-script', () => {
it('shares an identical timed modifier outcome across kill and assist refresh clauses', () => {
const weaponDamage = {
id: 'pummel-weapon-damage',
label: 'Weapon Damage',
phase: 'while_active' as const,
kind: 'attribute_modifier' as const,
scope: 'equipped_weapon' as const,
basis: 'flat' as const,
value: 10,
attributeId: 'weapon-damage',
statePool: 'pummel-active'
};
const program: MechanicProgram = {
id: 'pummel-refresh',
name: 'Pummel Refresh',
pools: [{
key: 'pummel-active', label: 'Pummel active', scope: 'agent', initial: 0,
min: 0, max: 1, expiresAfterSeconds: 12
}],
clauses: [
{
id: 'activate-on-kill', when: 'enemy_killed', reducer: { kind: 'none' },
mutations: [{ kind: 'set', pool: 'pummel-active', value: 1 }],
outcomes: [weaponDamage]
},
{
id: 'refresh-on-assist', when: 'assist', reducer: { kind: 'none' },
mutations: [{ kind: 'set', pool: 'pummel-active', value: 1 }],
outcomes: [{ ...weaponDamage }]
}
]
};
expect(validateMechanicProgram(program)).toMatchObject({ ok: true });
const result = evaluateMechanicProgram(program, [
{ kind: 'enemy_killed', at: 1 },
{ kind: 'assist', at: 10 }
], custom(20));
expect(result.status).toBe('OK');
expect(result.outcomes.map(({ outcomeId, at, appliedValue }) => ({ outcomeId, at, appliedValue })))
.toEqual([
{ outcomeId: 'pummel-weapon-damage', at: 1, appliedValue: 10 },
{ outcomeId: 'pummel-weapon-damage', at: 10, appliedValue: 10 }
]);
expect(result.finalState).toContainEqual(expect.objectContaining({
pool: 'pummel-active', value: 1, expiresAt: 22
}));
});
it('rejects a shared outcome ID when its mechanical definitions conflict', () => {
const outcome = {
id: 'shared-damage',
label: 'Weapon Damage',
phase: 'on_event' as const,
kind: 'attribute_modifier' as const,
scope: 'equipped_weapon' as const,
basis: 'flat' as const,
value: 10,
attributeId: 'weapon-damage'
};
const program: MechanicProgram = {
id: 'conflicting-shared-outcome',
name: 'Conflicting Shared Outcome',
clauses: [
{
id: 'kill', when: 'enemy_killed', reducer: { kind: 'none' },
mutations: [{ kind: 'none' }], outcomes: [outcome]
},
{
id: 'assist', when: 'assist', reducer: { kind: 'none' },
mutations: [{ kind: 'none' }], outcomes: [{ ...outcome, value: 14 }]
}
]
};
expect(validateMechanicProgram(program)).toEqual({
ok: false,
errors: ['Outcome "shared-damage" has conflicting definitions.']
});
});
it('applies distance bonuses in full five-meter steps and caps the result', () => {
const distanceBonus = (
base: number,
increment: number,
maximum: number
): NumericExpression => ({
op: 'min',
left: maximum,
right: {
op: 'add',
left: base,
right: {
op: 'multiply',
left: increment,
right: {
op: 'floor',
value: {
op: 'divide',
left: {
op: 'max',
left: { ref: { source: 'event', path: 'values.distanceMeters' } },
right: 0
},
right: 5
}
}
}
}
});
const program: MechanicProgram = {
id: 'long-distance-marking',
name: 'Long-Distance Marking',
clauses: [{
id: 'headshot',
when: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [],
outcomes: [{
id: 'skill-damage',
label: 'Skill Damage',
phase: 'after_event',
kind: 'attribute_modifier',
scope: 'self',
attributeId: 'skill-damage',
basis: 'flat',
value: distanceBonus(7.5, 1.5, 18)
}]
}]
};
expect(validateMechanicProgram(program)).toMatchObject({ ok: true });
const result = evaluateMechanicProgram(
program,
[-1, 0, 4.99, 5, 9.99, 10, 34.99, 35, 100].map((distanceMeters, at) => ({
kind: 'weapon_hit' as const,
at,
values: { distanceMeters }
})),
custom(10)
);
expect(result.status).toBe('OK');
expect(result.outcomes.map((outcome) => outcome.resolvedValue)).toEqual([
7.5, 7.5, 7.5, 9, 9, 10.5, 16.5, 18, 18
]);
expect(result.script).toContain(
'MIN(18, (7.5 + (1.5 * FLOOR((MAX(EVENT.values.distanceMeters, 0) / 5)))))'
);
});
it('supports an immediate equipped-weapon magazine refill', () => {
const program: MechanicProgram = {
id: 'equipped-magazine',
name: 'Equipped Magazine',
clauses: [{
id: 'refill-equipped-weapon',
when: 'enemy_killed',
reducer: { kind: 'none' },
mutations: [{ kind: 'none' }],
outcomes: [{
id: 'equipped-weapon-reload',
label: 'Refill equipped weapon',
phase: 'after_event',
kind: 'weapon_reload',
scope: 'equipped_weapon',
basis: 'flat',
value: 0
}]
}]
};
const result = evaluateMechanicProgram(
program,
[{ kind: 'enemy_killed', at: 1 }],
custom(10)
);
expect(result.status).toBe('OK');
expect(result.outcomes).toEqual([
expect.objectContaining({
kind: 'weapon_reload',
scope: 'equipped_weapon',
at: 1,
value: 0
})
]);
expect(result.script).toContain('RELOAD EQUIPPED WEAPON AFTER 0s');
});
it('schedules an inactive-weapon reload and refreshes a pending delay', () => {
const program: MechanicProgram = {
id: 'backup-magazine',
name: 'Backup Magazine',
clauses: [{
id: 'reload-inactive-weapon',
when: 'weapon_swapped',
reducer: { kind: 'none' },
mutations: [{ kind: 'none' }],
outcomes: [{
id: 'inactive-weapon-reload',
label: 'Reload inactive weapon',
phase: 'after_event',
kind: 'weapon_reload',
scope: 'inactive_weapon',
basis: 'flat',
value: 4
}]
}]
};
const refreshed = evaluateMechanicProgram(program, [
{ kind: 'weapon_swapped', at: 1 },
{ kind: 'weapon_swapped', at: 3 }
], custom(10));
expect(refreshed.status).toBe('OK');
expect(refreshed.emissions.map(({ at }) => at)).toEqual([1, 3]);
expect(refreshed.outcomes).toEqual([
expect.objectContaining({
kind: 'weapon_reload',
scope: 'inactive_weapon',
at: 7,
value: 4
})
]);
expect(refreshed.script).toContain('RELOAD INACTIVE WEAPON AFTER 4s');
const beyondEncounter = evaluateMechanicProgram(
program,
[{ kind: 'weapon_swapped', at: 8 }],
custom(10)
);
expect(beyondEncounter.outcomes).toEqual([]);
});
it('enforces clause cooldowns without counting events received during the cooldown', () => {
const program: MechanicProgram = {
id: 'cooldown',
@@ -1187,6 +1564,213 @@ describe('mechanic validation and pseudo-script', () => {
}
});
it('caps Signature Ability Charge at 1000 and scales grants by charge efficiency', () => {
const efficiencyAttributeId = 'signature-charge-efficiency';
const program: MechanicProgram = {
id: 'signature-charge',
name: 'Signature Charge',
clauses: [{
id: 'grant-charge',
when: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [],
outcomes: [{
id: 'charge',
label: 'Signature Ability Charge',
phase: 'after_event',
kind: 'restoration',
scope: 'self',
resource: 'signature_ability_charge',
basis: 'flat',
value: {
op: 'multiply',
left: 70,
right: {
op: 'add',
left: 1,
right: {
op: 'divide',
left: { ref: { source: 'attribute', attributeId: efficiencyAttributeId } },
right: 100
}
}
}
}]
}]
};
const result = evaluateMechanicProgram(
program,
[
{ kind: 'weapon_hit', at: 1 },
{ kind: 'weapon_hit', at: 2 }
],
custom(),
{
maxHealth: 100,
currentHealth: 100,
currentSignatureAbilityCharge: 900,
attributes: { [efficiencyAttributeId]: 50 }
}
);
expect(result.status).toBe('OK');
expect(result.outcomes.map((outcome) => outcome.appliedValue)).toEqual([100, 0]);
expect(result.finalCombat?.currentSignatureAbilityCharge).toBe(1000);
expect(result.finalCombat?.maxSignatureAbilityCharge).toBeUndefined();
});
it('evaluates mutually exclusive current Health percentage bands at strict boundaries', () => {
const band = (
id: string,
minimum: number | undefined,
maximum: number,
value: number
): MechanicProgram['clauses'][number] => ({
id,
when: 'tick',
if: minimum === undefined
? {
kind: 'compare',
left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' },
operator: 'lt',
right: maximum
}
: {
kind: 'all',
predicates: [{
kind: 'compare',
left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' },
operator: 'gte',
right: minimum
}, {
kind: 'compare',
left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' },
operator: 'lt',
right: maximum
}]
},
reducer: { kind: 'none' },
mutations: [],
outcomes: [{
id: `${id}-damage-reduction`,
label: 'Damage Reduction',
phase: 'while_active',
kind: 'attribute_modifier',
scope: 'self',
basis: 'flat',
attributeId: 'damage-reduction',
value
}]
});
const spirited: MechanicProgram = {
id: 'spirited',
name: 'Spirited',
clauses: [
band('critical', undefined, 25, 15),
band('damaged', 25, 50, 12),
band('wounded', 50, 75, 9)
]
};
const evaluateAt = (healthPercent: number, mode: 'custom' | 'maximum' = 'custom') =>
evaluateMechanicProgram(
spirited,
[{ kind: 'tick', at: 0 }],
mode === 'maximum'
? { mode: 'maximum', encounterSeconds: 1 }
: custom(1),
{ maxHealth: 1_000, currentHealth: healthPercent * 10 }
);
const valuesAt = (healthPercent: number, mode: 'custom' | 'maximum' = 'custom') =>
evaluateAt(healthPercent, mode).outcomes.map((outcome) => outcome.appliedValue);
expect(valuesAt(0)).toEqual([15]);
expect(valuesAt(24.99)).toEqual([15]);
expect(valuesAt(25)).toEqual([12]);
expect(valuesAt(49.99)).toEqual([12]);
expect(valuesAt(50)).toEqual([9]);
expect(valuesAt(74.99)).toEqual([9]);
expect(valuesAt(75)).toEqual([]);
expect(valuesAt(100)).toEqual([]);
expect(valuesAt(24, 'maximum')).toEqual([15]);
});
it('re-evaluates current Health percentage after deterministic combat-state updates', () => {
const program: MechanicProgram = {
id: 'live-health',
name: 'Live Health',
clauses: [{
id: 'health-cost',
when: 'tick',
reducer: { kind: 'none' },
mutations: [],
outcomes: [{
id: 'cost',
label: 'Health cost',
phase: 'on_event',
kind: 'resource_cost',
scope: 'self',
resource: 'health',
basis: 'flat',
value: 30
}]
}, {
id: 'below-seventy-five',
when: 'tick',
if: {
kind: 'compare',
left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' },
operator: 'lt',
right: 75
},
reducer: { kind: 'none' },
mutations: [],
outcomes: [{
id: 'reduction',
label: 'Damage Reduction',
phase: 'while_active',
kind: 'attribute_modifier',
scope: 'self',
attributeId: 'damage-reduction',
basis: 'flat',
value: 9
}]
}]
};
const result = evaluateMechanicProgram(
program,
[{ kind: 'tick', at: 0 }],
custom(1),
{ maxHealth: 100, currentHealth: 100 }
);
expect(result.finalCombat?.currentHealth).toBe(70);
expect(result.outcomes.map((outcome) => outcome.outcomeId)).toEqual(['cost', 'reduction']);
});
it('rejects pre-event timing for the derived current Health percentage', () => {
const invalid: MechanicProgram = {
id: 'invalid-health-percent',
name: 'Invalid Health Percent',
clauses: [{
id: 'invalid',
when: 'tick',
if: {
kind: 'compare',
left: { source: 'combat', path: 'currentHealthPercent', timing: 'pre_event' },
operator: 'lt',
right: 50
},
reducer: { kind: 'none' },
mutations: []
}]
};
const result = validateMechanicProgram(invalid);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors).toEqual(expect.arrayContaining([
expect.stringContaining('current Health percentage must use current timing')
]));
}
});
it('formats nested conditions and state operations deterministically', () => {
const program: MechanicProgram = {
id: 'format',
@@ -1221,4 +1805,49 @@ describe('mechanic validation and pseudo-script', () => {
'ADD 1 TO prepared'
].join('\n'));
});
it('preserves radius targeting on ally modifiers and enemy markers', () => {
const program: MechanicProgram = {
id: 'radius-targeting',
name: 'Radius Targeting',
clauses: [{
id: 'kill',
when: 'enemy_killed',
reducer: { kind: 'none' },
mutations: [{ kind: 'none' }],
outcomes: [{
id: 'healing',
label: 'Incoming Healing',
phase: 'on_event',
kind: 'attribute_modifier',
scope: 'allies',
attributeId: 'incoming-healing',
basis: 'flat',
value: 7,
targeting: { kind: 'within_radius', population: 'allies', radiusMeters: 40 }
}, {
id: 'taunt',
label: 'Taunt',
phase: 'on_event',
kind: 'marker',
scope: 'target',
basis: 'flat',
value: 1,
targeting: { kind: 'within_radius', population: 'enemies', radiusMeters: 20 }
}]
}]
};
const result = evaluateMechanicProgram(
program,
[{ kind: 'enemy_killed', at: 1 }],
custom(5)
);
expect(result.status).toBe('OK');
expect(result.outcomes.map((outcome) => outcome.targeting)).toEqual([
{ kind: 'within_radius', population: 'allies', radiusMeters: 40 },
{ kind: 'within_radius', population: 'enemies', radiusMeters: 20 }
]);
expect(result.script).toContain('TO ALLIES WITHIN 40m');
expect(result.script).toContain('TO ENEMIES WITHIN 20m');
});
});
+293 -28
View File
@@ -2,12 +2,19 @@ export const MECHANIC_EVENT_KINDS = [
'shot_fired',
'weapon_hit',
'skill_deployed',
'signature_ability_deployed',
'skill_hit',
'skill_multishot_triggered',
'agent_hit',
'health_damage_taken',
'damage_dealt',
'enemy_killed',
'assist',
'ally_downed',
'agent_downed',
'ally_rescued',
'grenade_thrown',
'medical_kit_used',
'healing_applied',
'extra_health_granted',
'movement_snapshot',
@@ -36,15 +43,31 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
shot_fired: {
key: 'shot_fired',
label: 'Shot fired',
description: 'A round leaves the equipped weapon; hit confirmation is not required.'
description: 'A round leaves the equipped weapon; hit confirmation is not required.',
fields: [
{
path: 'values.hit',
label: 'Shot hit',
type: 'boolean'
},
{
path: 'values.magazineTwentyPercentBandsCrossed',
label: '20% magazine bands crossed',
type: 'number'
}
]
},
weapon_hit: {
key: 'weapon_hit',
label: 'Weapon hit',
description: 'A weapon attack connects with a target. The headshot boolean distinguishes headshots from body shots.',
description: 'A weapon attack connects with a target. The previous target and event values distinguish target switches, headshots, cover state, and distance.',
fields: [
{ path: 'targetId', label: 'Target ID', type: 'string' },
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' }
{ path: 'values.previousTargetId', label: 'Previous target ID', type: 'string' },
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' },
{ path: 'values.critical', label: 'Critical hit', type: 'boolean' },
{ path: 'values.behindCover', label: 'Agent behind cover', type: 'boolean' },
{ path: 'values.distanceMeters', label: 'Target distance', type: 'number' }
]
},
skill_deployed: {
@@ -52,10 +75,24 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
label: 'Skill deployed',
description: 'The Agent deploys or activates a specialization ability.'
},
signature_ability_deployed: {
key: 'signature_ability_deployed',
label: 'Signature Ability deployed',
description: 'The Agent deploys or activates their Signature Ability.'
},
skill_hit: {
key: 'skill_hit',
label: 'Skill hit',
description: 'A deployed skill connects with a target. Critical and distance details belong in event values.'
description: 'A deployed skill connects with a target. Critical and distance details belong in event values.',
fields: [
{ path: 'values.critical', label: 'Critical hit', type: 'boolean' },
{ path: 'values.distanceMeters', label: 'Target distance', type: 'number' }
]
},
skill_multishot_triggered: {
key: 'skill_multishot_triggered',
label: 'Skill Multi-Shot triggered',
description: 'A deployed skill triggers an additional Multi-Shot.'
},
agent_hit: {
key: 'agent_hit',
@@ -65,7 +102,11 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
health_damage_taken: {
key: 'health_damage_taken',
label: 'Health damage taken',
description: 'The Agent loses Health. Damage absorbed entirely by Extra Health does not emit this event.'
description: 'The Agent loses Health. Damage absorbed entirely by Extra Health does not emit this event.',
fields: [
{ path: 'values.distanceMeters', label: 'Attacker distance', type: 'number' },
{ path: 'values.recentSkillDeploymentSeconds', label: 'Seconds since relevant Skill deployment', type: 'number' }
]
},
damage_dealt: {
key: 'damage_dealt',
@@ -75,12 +116,50 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
enemy_killed: {
key: 'enemy_killed',
label: 'Enemy killed',
description: 'An enemy dies. Weapon or skill source may be supplied when a mechanic requires the distinction.'
description: 'An enemy dies. Weapon or skill source may be supplied when a mechanic requires the distinction.',
fields: [
{ path: 'values.headshot', label: 'Headshot killing shot', type: 'boolean' },
{ path: 'values.critical', label: 'Critical killing shot', type: 'boolean' },
{ path: 'values.source', label: 'Killing source', type: 'string' },
{ path: 'values.behindCover', label: 'Agent behind cover', type: 'boolean' },
{ path: 'values.distanceMeters', label: 'Enemy distance', type: 'number' }
]
},
assist: {
key: 'assist',
label: 'Assist',
description: 'The Agent receives credit for assisting with an enemy defeat.'
description: 'The Agent receives credit for assisting with an enemy defeat.',
fields: [
{ path: 'values.behindCover', label: 'Agent behind cover', type: 'boolean' }
]
},
ally_downed: {
key: 'ally_downed',
label: 'Ally downed',
description: 'An allied Agent enters the downed state.',
fields: [
{ path: 'values.distanceMeters', label: 'Ally distance', type: 'number' }
]
},
agent_downed: {
key: 'agent_downed',
label: 'Agent downed',
description: 'The Agent enters the downed state.'
},
ally_rescued: {
key: 'ally_rescued',
label: 'Ally rescued',
description: 'The Agent completes a rescue of a downed ally.'
},
grenade_thrown: {
key: 'grenade_thrown',
label: 'Grenade thrown',
description: 'The Agent throws a grenade and consumes it from inventory.'
},
medical_kit_used: {
key: 'medical_kit_used',
label: 'Medical Kit used',
description: 'The Agent consumes a Medical Kit.'
},
healing_applied: {
key: 'healing_applied',
@@ -95,7 +174,14 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
movement_snapshot: {
key: 'movement_snapshot',
label: 'Movement snapshot',
description: 'A sampled Agent movement state such as immobile, moving, or sprinting.'
description: 'A one-second sample of the Agent movement state.',
fields: [
{
path: 'values.state',
label: 'Movement state (immobile, moving, or sprinting)',
type: 'string'
}
]
},
proximity_snapshot: {
key: 'proximity_snapshot',
@@ -115,7 +201,10 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
reload_started: {
key: 'reload_started',
label: 'Reload started',
description: 'The equipped weapon begins reloading.'
description: 'The equipped weapon begins reloading.',
fields: [
{ path: 'values.emptyMagazine', label: 'Empty magazine', type: 'boolean' }
]
},
reload_completed: {
key: 'reload_completed',
@@ -178,9 +267,21 @@ export type ValueReference =
| { source: 'reducer' }
| { source: 'pool'; pool: string; entity?: EntitySelector };
export const MECHANIC_COMBAT_REFERENCE_REGISTRY = {
currentHealth: {
label: 'Current Health',
description: 'The Agent’s resolved current Health.'
},
currentHealthPercent: {
label: 'Current Health percentage',
description: 'The Agent’s resolved current Health divided by resolved maximum Health, from 0 through 100.'
}
} as const;
export type NumericExpression =
| number
| { ref: ValueReference }
| { op: 'floor'; value: NumericExpression }
| {
op: 'add' | 'subtract' | 'multiply' | 'divide' | 'min' | 'max';
left: NumericExpression;
@@ -256,19 +357,26 @@ export type OutcomeBasis =
export type MechanicOutcomeScope =
| 'self'
| 'equipped_weapon'
| 'inactive_weapon'
| 'target'
| 'allies'
| 'self_and_allies'
| 'assigned_ability'
| 'all_skills';
export type MechanicResource = 'health' | 'armor' | 'ammo';
export type MechanicResource = 'health' | 'armor' | 'ammo' | 'signature_ability_charge';
export type MechanicOutcomeTargeting = {
kind: 'within_radius';
population: 'allies' | 'enemies';
radiusMeters: number;
};
export type MechanicOutcome = {
id: string;
label: string;
phase: OutcomePhase;
kind: 'attribute_modifier' | 'direct_damage' | 'restoration' | 'resource_cost' | 'marker';
kind: 'attribute_modifier' | 'direct_damage' | 'restoration' | 'resource_cost' | 'weapon_reload' | 'marker';
scope: MechanicOutcomeScope;
value: NumericExpression;
basis: OutcomeBasis;
@@ -281,6 +389,7 @@ export type MechanicOutcome = {
perStack?: boolean;
minStacks?: number;
maxStacks?: number;
targeting?: MechanicOutcomeTargeting;
};
export type MechanicClause = {
@@ -304,6 +413,23 @@ export type MechanicProgram = {
clauses: MechanicClause[];
};
function mechanicDefinitionsEqual(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true;
if (typeof left !== typeof right || left === null || right === null) return false;
if (Array.isArray(left) || Array.isArray(right)) {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
return left.every((value, index) => mechanicDefinitionsEqual(value, right[index]));
}
if (typeof left !== 'object') return false;
const leftRecord = left as Record<string, unknown>;
const rightRecord = right as Record<string, unknown>;
const leftKeys = Object.keys(leftRecord).sort();
const rightKeys = Object.keys(rightRecord).sort();
return leftKeys.length === rightKeys.length
&& leftKeys.every((key, index) =>
key === rightKeys[index] && mechanicDefinitionsEqual(leftRecord[key], rightRecord[key]));
}
export type PoolSnapshot = {
pool: string;
scope: StateScope;
@@ -354,6 +480,7 @@ export type MechanicOutcomeEmission = {
deliveryStart: number | null;
deliveryEnd: number | null;
totalResolvedValue: number;
targeting: MechanicOutcomeTargeting | null;
};
export type MechanicEvaluation = {
@@ -382,6 +509,8 @@ export type MechanicCombatContext = {
currentArmor?: number;
maxAmmo?: number;
currentAmmo?: number;
maxSignatureAbilityCharge?: number;
currentSignatureAbilityCharge?: number;
weaponBaseDamage?: number;
attributes?: Record<string, number>;
values?: Record<string, Scalar>;
@@ -425,6 +554,7 @@ type ContinuousDelivery = {
start: number;
end: number;
appliedThrough: number;
targeting: MechanicOutcomeTargeting | null;
};
type ReducerState = {
count: number;
@@ -494,6 +624,15 @@ function resolveReference(reference: ValueReference, context: EvaluationContext)
return context.scenario[reference.key];
case 'combat': {
const combat = reference.timing === 'pre_event' ? context.preEventCombat : context.combat;
if (reference.path === 'currentHealthPercent') {
if (
!combat
|| !finiteNumber(combat.maxHealth)
|| combat.maxHealth <= 0
|| !finiteNumber(combat.currentHealth)
) return undefined;
return clamp(combat.currentHealth / combat.maxHealth * 100, 0, 100);
}
return getPath(combat, reference.path);
}
case 'attribute':
@@ -513,6 +652,7 @@ function numericValue(expression: NumericExpression, context: EvaluationContext)
const value = resolveReference(expression.ref, context);
return finiteNumber(value) ? value : typeof value === 'boolean' ? Number(value) : 0;
}
if (expression.op === 'floor') return Math.floor(numericValue(expression.value, context));
if (expression.op === 'clamp') {
return clamp(
numericValue(expression.value, context),
@@ -893,6 +1033,19 @@ function combatResource(
setCurrent: (value) => { combat.currentAmmo = value; }
};
}
if (resource === 'signature_ability_charge') {
const maximum = finiteNumber(combat.maxSignatureAbilityCharge)
? combat.maxSignatureAbilityCharge
: 1000;
const current = finiteNumber(combat.currentSignatureAbilityCharge)
? combat.currentSignatureAbilityCharge
: 0;
return {
current,
maximum,
setCurrent: (value) => { combat.currentSignatureAbilityCharge = value; }
};
}
return null;
}
@@ -957,7 +1110,8 @@ function emitOutcomes(
stateValue: current,
start: at,
end: at + durationSeconds,
appliedThrough: at
appliedThrough: at,
targeting: outcome.targeting ?? null
});
continue;
}
@@ -973,10 +1127,13 @@ function emitOutcomes(
} else if ((outcome.kind === 'resource_cost' || outcome.kind === 'restoration') && !targetResource) {
appliedValue = 0;
}
const emittedAt = outcome.kind === 'weapon_reload'
? at + Math.max(0, appliedValue)
: at;
emissions.push({
clauseId: clause.id,
outcomeId: outcome.id,
at,
at: emittedAt,
targetId: context.event.targetId ?? null,
phase: outcome.phase,
kind: outcome.kind,
@@ -995,12 +1152,40 @@ function emitOutcomes(
delivery: 'instant',
deliveryStart: null,
deliveryEnd: null,
totalResolvedValue: resolvedValue * weight
totalResolvedValue: resolvedValue * weight,
targeting: outcome.targeting ?? null
});
}
return emissions;
}
function appendScheduledOutcomes(
target: MechanicOutcomeEmission[],
candidates: MechanicOutcomeEmission[],
triggeredAt: number,
encounterSeconds: number
) {
for (const candidate of candidates) {
if (candidate.kind !== 'weapon_reload') {
target.push(candidate);
continue;
}
for (let index = target.length - 1; index >= 0; index -= 1) {
const pending = target[index];
if (
pending.kind === 'weapon_reload'
&& pending.clauseId === candidate.clauseId
&& pending.outcomeId === candidate.outcomeId
&& pending.scope === candidate.scope
&& pending.at > triggeredAt + EPSILON
) {
target.splice(index, 1);
}
}
if (candidate.at <= encounterSeconds + EPSILON) target.push(candidate);
}
}
function advanceContinuousDeliveries(
at: number,
deliveries: ContinuousDelivery[],
@@ -1046,7 +1231,8 @@ function advanceContinuousDeliveries(
delivery: 'continuous',
deliveryStart: delivery.start,
deliveryEnd: delivery.end,
totalResolvedValue: delivery.totalResolvedValue
totalResolvedValue: delivery.totalResolvedValue,
targeting: delivery.targeting
});
}
for (let index = deliveries.length - 1; index >= 0; index -= 1) {
@@ -1182,7 +1368,12 @@ function validateReference(reference: ValueReference, pools: Set<string>, inputs
errors.push(`${path} references unknown scenario input "${reference.key}".`);
}
if (reference.source === 'event' && !reference.path.trim()) errors.push(`${path} requires an event path.`);
if (reference.source === 'combat' && !reference.path.trim()) errors.push(`${path} requires a combat path.`);
if (reference.source === 'combat') {
if (!reference.path.trim()) errors.push(`${path} requires a combat path.`);
if (reference.path === 'currentHealthPercent' && reference.timing === 'pre_event') {
errors.push(`${path} current Health percentage must use current timing.`);
}
}
if (reference.source === 'attribute' && !reference.attributeId.trim()) errors.push(`${path} requires an Attribute ID.`);
if (reference.source === 'equipment' && !reference.path.trim()) errors.push(`${path} requires an equipment path.`);
}
@@ -1202,6 +1393,10 @@ function visitExpression(
validateReference(expression.ref, pools, inputs, errors, path);
return;
}
if (expression.op === 'floor') {
visitExpression(expression.value, pools, inputs, errors, `${path} value`);
return;
}
if (expression.op === 'clamp') {
visitExpression(expression.value, pools, inputs, errors, `${path} value`);
visitExpression(expression.min, pools, inputs, errors, `${path} minimum`);
@@ -1245,6 +1440,7 @@ function visitPredicate(
function expressionUsesCombat(expression: NumericExpression): boolean {
if (typeof expression === 'number') return false;
if ('ref' in expression) return expression.ref.source === 'combat';
if (expression.op === 'floor') return expressionUsesCombat(expression.value);
if (expression.op === 'clamp') {
return expressionUsesCombat(expression.value)
|| expressionUsesCombat(expression.min)
@@ -1256,6 +1452,7 @@ function expressionUsesCombat(expression: NumericExpression): boolean {
function expressionUsesEquipment(expression: NumericExpression): boolean {
if (typeof expression === 'number') return false;
if ('ref' in expression) return expression.ref.source === 'equipment';
if (expression.op === 'floor') return expressionUsesEquipment(expression.value);
if (expression.op === 'clamp') {
return expressionUsesEquipment(expression.value)
|| expressionUsesEquipment(expression.min)
@@ -1390,7 +1587,7 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
}
}
const clauseIds = new Set<string>();
const outcomeIds = new Set<string>();
const outcomeDefinitions = new Map<string, MechanicOutcome>();
for (const [index, clause] of program.clauses.entries()) {
const path = `Clause ${index + 1}`;
if (!clause.id.trim()) errors.push(`${path} requires an ID.`);
@@ -1478,14 +1675,19 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
for (const [outcomeIndex, outcome] of (clause.outcomes ?? []).entries()) {
const outcomePath = `${path} outcome ${outcomeIndex + 1}`;
if (!outcome.id.trim()) errors.push(`${outcomePath} requires an ID.`);
if (outcomeIds.has(outcome.id)) errors.push(`Outcome "${outcome.id}" is duplicated.`);
outcomeIds.add(outcome.id);
const existingOutcome = outcomeDefinitions.get(outcome.id);
if (existingOutcome && !mechanicDefinitionsEqual(existingOutcome, outcome)) {
errors.push(`Outcome "${outcome.id}" has conflicting definitions.`);
} else if (!existingOutcome) {
outcomeDefinitions.set(outcome.id, outcome);
}
if (outcome.kind === 'attribute_modifier' && !outcome.attributeId?.trim()) {
errors.push(`${outcomePath} requires an Attribute ID.`);
}
if (![
'self',
'equipped_weapon',
'inactive_weapon',
'target',
'allies',
'self_and_allies',
@@ -1494,11 +1696,45 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
].includes(outcome.scope)) {
errors.push(`${outcomePath} has an unsupported scope.`);
}
if (
outcome.kind === 'weapon_reload'
&& outcome.scope !== 'equipped_weapon'
&& outcome.scope !== 'inactive_weapon'
) {
errors.push(`${outcomePath} weapon reload must target the equipped or inactive weapon.`);
}
if (outcome.scope === 'inactive_weapon' && outcome.kind !== 'weapon_reload') {
errors.push(`${outcomePath} can only target the inactive weapon with a weapon reload.`);
}
if (outcome.targeting !== undefined) {
if (
outcome.targeting.kind !== 'within_radius'
|| !['allies', 'enemies'].includes(outcome.targeting.population)
|| !finiteNumber(outcome.targeting.radiusMeters)
|| outcome.targeting.radiusMeters <= 0
) {
errors.push(`${outcomePath} has invalid radius targeting.`);
} else if (
outcome.targeting.population === 'allies'
&& outcome.scope !== 'allies'
&& outcome.scope !== 'self_and_allies'
) {
errors.push(`${outcomePath} ally radius targeting requires allies or self-and-allies scope.`);
} else if (
outcome.targeting.population === 'enemies'
&& outcome.scope !== 'target'
) {
errors.push(`${outcomePath} enemy radius targeting requires target scope.`);
}
}
if (outcome.kind === 'weapon_reload' && finiteNumber(outcome.value) && outcome.value < 0) {
errors.push(`${outcomePath} weapon reload delay cannot be negative.`);
}
if ((outcome.kind === 'restoration' || outcome.kind === 'resource_cost') && outcome.resource === undefined) {
errors.push(`${outcomePath} requires a resource.`);
}
if ((outcome.kind === 'restoration' || outcome.kind === 'resource_cost') && outcome.resource !== undefined
&& !['health', 'armor', 'ammo'].includes(outcome.resource)) {
&& !['health', 'armor', 'ammo', 'signature_ability_charge'].includes(outcome.resource)) {
errors.push(`${outcomePath} has an unsupported resource.`);
}
if (outcome.resource !== undefined && outcome.kind !== 'restoration' && outcome.kind !== 'resource_cost') {
@@ -1522,6 +1758,7 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
direct_damage: ['flat', 'max_health_percent', 'current_health_percent', 'weapon_base_percent'],
restoration: ['flat', 'max_health_percent', 'current_health_percent'],
resource_cost: ['flat', 'max_health_percent', 'current_health_percent'],
weapon_reload: ['flat'],
marker: ['flat']
};
if (!allowedBasis[outcome.kind].includes(outcome.basis)) {
@@ -1644,6 +1881,10 @@ export function evaluateMechanicProgram(
const continuousDeliveries: ContinuousDelivery[] = [];
if (scenario.mode === 'maximum') {
for (const clause of program.clauses) {
if (
clause.when === 'tick'
&& events.some((event) => event.kind === 'tick' && Math.abs(event.at) <= EPSILON)
) continue;
const event: MechanicEvent = {
kind: clause.when,
at: 0,
@@ -1660,14 +1901,15 @@ export function evaluateMechanicProgram(
preEventCombat: copyCombat(combat),
equipment
};
if (!predicateMatches(clause.if, context)) continue;
const before = new Map((clause.outcomes ?? [])
.map((outcome) => outcome.statePool)
.filter((pool): pool is string => Boolean(pool))
.map((pool) => [pool, stateValue(pool, undefined, context)]));
outcomes.push(...emitOutcomes({
appendScheduledOutcomes(outcomes, emitOutcomes({
...clause,
outcomes: (clause.outcomes ?? []).filter((outcome) => outcome.phase === 'while_active')
}, 'after', 0, 1, before, context, continuousDeliveries));
}, 'after', 0, 1, before, context, continuousDeliveries), 0, scenario.encounterSeconds);
}
}
const ordered = events
@@ -1676,7 +1918,19 @@ export function evaluateMechanicProgram(
.sort((left, right) => left.event.at - right.event.at || left.index - right.index)
.map(({ event }) => event);
for (const event of ordered) {
let previousWeaponHitTargetId: string | null = null;
for (const rawEvent of ordered) {
const event: MechanicEvent = rawEvent.kind === 'weapon_hit' && rawEvent.targetId
? {
...rawEvent,
values: {
...(rawEvent.values ?? {}),
...(previousWeaponHitTargetId === null
? {}
: { previousTargetId: previousWeaponHitTargetId })
}
}
: rawEvent;
outcomes.push(...expirePools(
event.at,
program.clauses,
@@ -1716,7 +1970,7 @@ export function evaluateMechanicProgram(
.map((outcome) => outcome.statePool)
.filter((pool): pool is string => Boolean(pool)));
const before = new Map([...relevantPools].map((pool) => [pool, stateValue(pool, undefined, context)]));
outcomes.push(...emitOutcomes(
appendScheduledOutcomes(outcomes, emitOutcomes(
clause,
'before',
reduction.at,
@@ -1724,7 +1978,7 @@ export function evaluateMechanicProgram(
before,
context,
continuousDeliveries
));
), reduction.at, scenario.encounterSeconds);
const mutationRecords = clause.mutations.map((mutation) =>
applyMutation(mutation, reduction.at, context, weight));
emissions.push({
@@ -1737,7 +1991,7 @@ export function evaluateMechanicProgram(
if (clause.cooldownSeconds) {
cooldowns.set(stateKey, reduction.at + clause.cooldownSeconds);
}
outcomes.push(...emitOutcomes(
appendScheduledOutcomes(outcomes, emitOutcomes(
clause,
'after',
reduction.at,
@@ -1745,9 +1999,12 @@ export function evaluateMechanicProgram(
before,
context,
continuousDeliveries
));
), reduction.at, scenario.encounterSeconds);
}
}
if (event.kind === 'weapon_hit' && event.targetId) {
previousWeaponHitTargetId = event.targetId;
}
}
outcomes.push(...expirePools(
scenario.encounterSeconds,
@@ -1821,6 +2078,7 @@ function formatReference(reference: ValueReference): string {
function formatExpression(expression: NumericExpression): string {
if (typeof expression === 'number') return formatNumber(expression);
if ('ref' in expression) return formatReference(expression.ref);
if (expression.op === 'floor') return `FLOOR(${formatExpression(expression.value)})`;
if (expression.op === 'clamp') {
return `CLAMP(${formatExpression(expression.value)}, ${formatExpression(expression.min)}, ${formatExpression(expression.max)})`;
}
@@ -1916,7 +2174,14 @@ function formatOutcome(outcome: MechanicOutcome): string {
const delivery = outcome.delivery === 'continuous'
? `; DELIVER TOTAL CONTINUOUSLY OVER ${formatNumber(outcome.durationSeconds ?? 0)}s`
: '';
return `${outcome.phase.toUpperCase()}: APPLY ${formatExpression(outcome.value)} ${outcome.basis.toUpperCase()}${resource}${attribute}${perStack}${stackBand}${timing}${delivery} // ${outcome.label}`;
const targeting = outcome.targeting
? ` TO ${outcome.targeting.population.toUpperCase()} WITHIN ${formatNumber(outcome.targeting.radiusMeters)}m`
: '';
if (outcome.kind === 'weapon_reload') {
const target = outcome.scope === 'equipped_weapon' ? 'EQUIPPED' : 'INACTIVE';
return `${outcome.phase.toUpperCase()}: RELOAD ${target} WEAPON AFTER ${formatExpression(outcome.value)}s // ${outcome.label}`;
}
return `${outcome.phase.toUpperCase()}: APPLY ${formatExpression(outcome.value)} ${outcome.basis.toUpperCase()}${resource}${attribute}${perStack}${stackBand}${timing}${targeting}${delivery} // ${outcome.label}`;
}
export function formatMechanicProgram(program: MechanicProgram): string {
+2 -2
View File
@@ -248,7 +248,7 @@ function stageFactorAt(
return Math.max(EPSILON, (1 + values.additive / 100) * values.multiplicative);
}
function activationTimes(
export function skillActivationTimes(
ability: SkillAbilityInput,
encounterSeconds: number,
input: SkillCalculationInput,
@@ -483,7 +483,7 @@ export function calculateSkillDps(input: SkillCalculationInput): SkillCalculatio
};
}
const activations = activationTimes(ability, encounterSeconds, input, rules);
const activations = skillActivationTimes(ability, encounterSeconds, input, rules);
const events = damageEvents(ability, activations, encounterSeconds, input, rules);
const damageByEvent = events.map((event) =>
expectedDamage(
@@ -42,6 +42,46 @@ describe('talent description outcomes', () => {
expect(missingTalentDescriptionSlots('Restore {1} over time.', outcomes)).toEqual([]);
});
it('accepts a numbered marker outcome for a structured timing value', () => {
const outcomes = numberedTalentOutcomes(draft([{
key: 'delayed-reload',
label: 'Delayed reload',
phase: 'after_event',
kind: 'marker',
scope: 'equipped_weapon',
basis: 'flat',
descriptionSlot: 1,
variants: {
I: { value: 4 },
II: { value: 3 }
}
}]));
expect(outcomes).toHaveLength(1);
expect(outcomes[0]).toMatchObject({ kind: 'marker', descriptionSlot: 1 });
expect(missingTalentDescriptionSlots('Reload after {1} seconds.', outcomes)).toEqual([]);
});
it('accepts a numbered weapon-reload delay', () => {
const outcomes = numberedTalentOutcomes(draft([{
key: 'inactive-reload',
label: 'Inactive weapon reload',
phase: 'after_event',
kind: 'weapon_reload',
scope: 'inactive_weapon',
basis: 'flat',
descriptionSlot: 1,
variants: {
I: { value: 4 },
II: { value: 3 }
}
}]));
expect(outcomes).toHaveLength(1);
expect(outcomes[0]).toMatchObject({ kind: 'weapon_reload', descriptionSlot: 1 });
expect(missingTalentDescriptionSlots('Reload after {1} seconds.', outcomes)).toEqual([]);
});
it('reports exact missing slots instead of relying on outcome count', () => {
const outcomes = numberedTalentOutcomes(draft([{
key: 'damage',
+3 -1
View File
@@ -7,7 +7,9 @@ const VALUE_BEARING_OUTCOME_KINDS = new Set([
'attribute_modifier',
'direct_damage',
'restoration',
'resource_cost'
'resource_cost',
'weapon_reload',
'marker'
]);
export const numberedTalentOutcomes = (
+13 -4
View File
@@ -5,6 +5,9 @@
id: string;
name: string;
unit?: string;
detail?: string;
disabled?: boolean;
disabledReason?: string;
pending?: boolean;
proposerName?: string;
iconUrl?: string | null;
@@ -52,8 +55,14 @@
const visible = $derived(presentation === 'list' ? available.slice(0, 30) : available);
const selectedItems = $derived(selectedIds.map((id) => items.find((item) => item.id === id)).filter((item): item is Item => Boolean(item)));
const unitLabel = (item: Item) => item.unit === undefined ? '' : item.unit || 'FLAT';
const accessibleName = (item: Item) => unitLabel(item) ? `${item.name}, unit ${unitLabel(item)}` : item.name;
const accessibleName = (item: Item) => [
item.name,
unitLabel(item) ? `unit ${unitLabel(item)}` : '',
item.detail ?? '',
item.disabledReason ?? ''
].filter(Boolean).join(', ');
const toggle = (id: string) => {
if (items.find((item) => item.id === id)?.disabled && !selectedIds.includes(id)) return;
if (mode === 'single') {
value = value === id ? '' : id;
onvaluechange?.(value);
@@ -77,7 +86,7 @@
{#each selectedItems as item}
<button type="button" class:pending-reference={item.pending} onclick={() => toggle(item.id)} aria-label={`Remove ${accessibleName(item)}`}>
{#if item.showIcon || item.iconUrl}<ItemIcon src={item.iconUrl} alt="" className="assignment-item-icon assignment-chip-icon"/>{/if}
<span>{item.name}{#if unitLabel(item)}<small class="assignment-item-unit">UNIT · {unitLabel(item)}</small>{/if}{#if item.pending}<small>PENDING</small>{/if}</span><b>×</b>
<span>{item.name}{#if unitLabel(item)}<small class="assignment-item-unit">UNIT · {unitLabel(item)}</small>{/if}{#if item.detail}<small>{item.detail}</small>{/if}{#if item.pending}<small>PENDING</small>{/if}</span><b>×</b>
</button>
{:else}
<small>{emptySelectionLabel}</small>
@@ -89,12 +98,12 @@
{/if}
<div class="assignment-results" role="list">
{#each visible as item}
<button type="button" class:selected={selectedIds.includes(item.id)} class:pending-reference={item.pending} class:with-icon={Boolean(item.showIcon || item.iconUrl)} class:visual-grid-option={presentation === 'visual-grid'} class:toggle-grid-option={presentation === 'toggle-grid'} aria-pressed={selectedIds.includes(item.id)} aria-label={presentation !== 'list' ? `${accessibleName(item)}, ${selectedIds.includes(item.id) ? 'assigned' : 'not assigned'}` : undefined} data-tooltip={presentation === 'visual-grid' ? item.name : undefined} onclick={() => toggle(item.id)}>
<button type="button" class:selected={selectedIds.includes(item.id)} class:pending-reference={item.pending} class:with-icon={Boolean(item.showIcon || item.iconUrl)} class:visual-grid-option={presentation === 'visual-grid'} class:toggle-grid-option={presentation === 'toggle-grid'} aria-pressed={selectedIds.includes(item.id)} aria-label={presentation !== 'list' ? `${accessibleName(item)}, ${selectedIds.includes(item.id) ? 'assigned' : 'not assigned'}` : undefined} data-tooltip={presentation === 'visual-grid' ? item.name : undefined} disabled={item.disabled && !selectedIds.includes(item.id)} title={item.disabledReason} onclick={() => toggle(item.id)}>
{#if presentation === 'visual-grid' || item.showIcon || item.iconUrl}<ItemIcon src={item.iconUrl} alt="" className={`assignment-item-icon ${presentation === 'visual-grid' ? 'assignment-grid-icon' : 'assignment-result-icon'}`}/>{/if}
{#if presentation === 'toggle-grid'}
<span>{item.name}</span>
{:else if presentation === 'list'}
<span>{item.name}{#if unitLabel(item)}<small class="assignment-item-unit">UNIT · {unitLabel(item)}</small>{/if}{#if item.pending}<small>PENDING · {item.proposerName ?? 'CONTRIBUTOR'}</small>{/if}</span><b>{selectedIds.includes(item.id) ? (mode === 'single' ? 'SELECTED' : 'ASSIGNED') : (mode === 'single' ? 'SELECT →' : 'ADD +')}</b>
<span>{item.name}{#if unitLabel(item)}<small class="assignment-item-unit">UNIT · {unitLabel(item)}</small>{/if}{#if item.detail}<small>{item.detail}</small>{/if}{#if item.pending}<small>PENDING · {item.proposerName ?? 'CONTRIBUTOR'}</small>{/if}</span><b>{item.disabled && !selectedIds.includes(item.id) ? 'UNAVAILABLE' : selectedIds.includes(item.id) ? (mode === 'single' ? 'SELECTED' : 'ASSIGNED') : (mode === 'single' ? 'SELECT →' : 'ADD +')}</b>
{/if}
</button>
{:else}
+19 -10
View File
@@ -8,6 +8,7 @@
meta: string;
summary: string;
iconUrl?: string;
issue?: string;
};
let {
@@ -34,6 +35,12 @@
active === cell.id
|| (cell.id.startsWith('ability:') && active.startsWith('chip:')
&& Math.floor(Number(active.split(':')[1]) / 3) === Number(cell.id.split(':')[1]));
const issueLabel = (issue: string) => {
if (/not configured|required/i.test(issue)) return 'ACTION REQUIRED';
if (/incomplete/i.test(issue)) return 'DATA INCOMPLETE';
return 'CHECK CONFIGURATION';
};
</script>
<section class="loadout-matrix" aria-label="Loadout matrix">
@@ -48,19 +55,20 @@
type="button"
class="doctrine-cell"
class:active={selected(doctrine)}
class:needs-attention={Boolean(doctrine.issue)}
aria-pressed={selected(doctrine)}
onclick={() => onselect(doctrine.id)}
>
<ItemIcon src={doctrine.iconUrl} alt="" className="matrix-icon" />
<span><small>{doctrine.label}</small><strong>{doctrine.name}</strong><em>{doctrine.summary}</em></span>
<span><small>{doctrine.label}</small><strong>{doctrine.name}</strong><em>{doctrine.summary}</em>{#if doctrine.issue}<mark title={doctrine.issue} aria-hidden="true">{issueLabel(doctrine.issue)}</mark><span class="sr-only">{doctrine.issue}</span>{/if}</span>
<i aria-hidden="true"></i>
</button>
<div class="supply-line"><span>SUPPLIES 3 ABILITIES</span></div>
<div class="ability-cells">
{#each abilities as cell}
<button type="button" class:active={selected(cell)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em></span>
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
</button>
{/each}
</div>
@@ -70,9 +78,9 @@
<small class="section-label">WEAPONS · PRIMARY + SECONDARY</small>
<div class="equipment-cells">
{#each weapons as cell}
<button type="button" class:active={selected(cell)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em></span>
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<i aria-hidden="true"></i>
</button>
{/each}
@@ -83,9 +91,9 @@
<small class="section-label">GEAR · 6 POSITIONS</small>
<div class="equipment-cells gear-cells">
{#each gear as cell}
<button type="button" class:active={selected(cell)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em></span>
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<i aria-hidden="true"></i>
</button>
{/each}
@@ -94,9 +102,9 @@
<div class="matrix-section protocol-section">
<small class="section-label">AGENT SYSTEM · ONE OS PROTOCOL</small>
<button type="button" class:active={selected(protocol)} aria-pressed={selected(protocol)} onclick={() => onselect(protocol.id)}>
<button type="button" class:active={selected(protocol)} class:needs-attention={Boolean(protocol.issue)} aria-pressed={selected(protocol)} onclick={() => onselect(protocol.id)}>
<ItemIcon src={protocol.iconUrl} alt="" className="matrix-icon" />
<span><small>{protocol.label}</small><strong>{protocol.name}</strong><em>{protocol.summary}</em></span>
<span><small>{protocol.label}</small><strong>{protocol.name}</strong><em>{protocol.summary}</em>{#if protocol.issue}<mark title={protocol.issue} aria-hidden="true">{issueLabel(protocol.issue)}</mark><span class="sr-only">{protocol.issue}</span>{/if}</span>
<i aria-hidden="true"></i>
</button>
</div>
@@ -113,9 +121,10 @@
:global(.matrix-icon){width:30px;height:30px;object-fit:contain}
button>span{min-width:0;display:grid;gap:2px}button small,button em{overflow:hidden;color:var(--shd-text-dim);font-size:9px;font-style:normal;letter-spacing:.04em;text-overflow:ellipsis;white-space:nowrap}button strong{overflow:hidden;font-size:11px;text-overflow:ellipsis;white-space:nowrap}button>i{align-self:stretch;background:var(--quality-high-end)}
button:hover,button:focus-visible{border-color:var(--shd-border-strong);background:var(--shd-surface-active);outline:none}button.active{border-color:var(--shd-builder-selection-border);background:var(--shd-builder-selection);box-shadow:inset 2px 0 var(--shd-orange)}
button.needs-attention{border-bottom-color:color-mix(in srgb,var(--shd-danger) 68%,var(--shd-border-soft))}button.needs-attention>i{background:var(--shd-danger)}button mark{width:fit-content;max-width:100%;overflow:hidden;margin-top:2px;padding:2px 4px;border:1px solid color-mix(in srgb,var(--shd-danger) 58%,transparent);background:#1d1212;color:var(--shd-danger);font-size:8px;font-weight:900;letter-spacing:.07em;line-height:1.2;text-overflow:ellipsis;text-transform:uppercase;white-space:nowrap}
.supply-line{height:22px;display:grid;place-items:center;background:linear-gradient(90deg,transparent 49.7%,var(--shd-orange) 49.7% 50.3%,transparent 50.3%)}.supply-line span{padding:0 6px;background:var(--shd-surface);color:var(--shd-orange);font-size:8px;font-weight:900;letter-spacing:.08em}
.ability-cells{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:3px}.ability-cells button{min-height:68px;grid-template-columns:25px minmax(0,1fr);padding:6px}.ability-cells :global(.matrix-icon){width:24px;height:24px}
.equipment-cells{display:grid;grid-template-columns:1fr 1fr;gap:3px}.equipment-cells button{min-height:69px}.protocol-section>button{width:100%;min-height:68px}
.equipment-cells{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:3px}.equipment-cells button{min-height:69px}.protocol-section>button{width:100%;min-height:68px}
button:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px}
@media(max-width:1240px){.ability-cells{grid-template-columns:1fr}.ability-cells button{min-height:48px}}
@media(max-width:800px){.ability-cells{grid-template-columns:repeat(3,minmax(0,1fr))}}
@@ -3,10 +3,12 @@
let {
result,
abilityNames = {}
abilityNames = {},
resolverHint = 'Complete the required Specialization ability data.'
}: {
result: SkillCalculation;
abilityNames?: Record<string, string>;
resolverHint?: string;
} = $props();
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });
</script>
@@ -17,10 +19,10 @@
<b>{result.status === 'OK' ? 'CALCULATED' : 'INSUFFICIENT DATA'}</b>
</header>
{#if result.status === 'INSUFFICIENT_DATA'}
<div class="missing">
<strong>Skill calculation unavailable</strong>
<span>ISAC requires the following data:</span>
<ul>{#each result.missing as item}<li>{item.replaceAll(':', ' · ')}</li>{/each}</ul>
<div class="missing" role="status">
<strong>SKILL OUTPUT BLOCKED</strong>
<span>{result.missing[0]?.replaceAll(':', ' · ') ?? 'Missing ability data'}</span>
<small>{result.missing.length > 1 ? `+${result.missing.length - 1} additional requirement${result.missing.length === 2 ? '' : 's'}. ` : ''}{resolverHint}</small>
</div>
{:else}
<div class="combined">
@@ -39,19 +41,19 @@
{/each}
</div>
{/if}
<footer>SKILLS CAN CRIT AND MULTI-SHOT · SKILLS DO NOT HEADSHOT · ENCOUNTER TRIGGERS FOLLOW THE PRIMARY WEAPON TIMELINE</footer>
{#if result.status === 'OK'}<footer>SKILLS CAN CRIT AND MULTI-SHOT · SKILLS DO NOT HEADSHOT · ENCOUNTER TRIGGERS FOLLOW THE PRIMARY WEAPON TIMELINE</footer>{/if}
</article>
<style>
.skill-calculation{display:grid;gap:var(--shd-gap-md);padding:var(--shd-gap-md);border:1px solid var(--shd-border);border-top:2px solid var(--shd-info);background:var(--shd-surface)}
.skill-calculation.incomplete{border-top-color:var(--shd-danger)}
.skill-calculation.incomplete{gap:6px;padding:8px;border-top-color:var(--shd-danger)}
header{display:flex;justify-content:space-between;align-items:start;gap:var(--shd-gap-md)}
header p,h3{margin:0}header p{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.1px}h3{text-transform:uppercase;font-size:var(--shd-type-body);letter-spacing:.7px}
header>b{padding:5px 7px;border:1px solid var(--shd-info);color:var(--shd-info);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.incomplete header>b{border-color:var(--shd-danger);color:var(--shd-danger)}
header>b{padding:5px 7px;border:1px solid var(--shd-info);color:var(--shd-info);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.incomplete header h3{font-size:10px}.incomplete header>b{padding:3px 5px;border-color:var(--shd-danger);color:var(--shd-danger);font-size:8px}
.combined{display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--shd-border)}.combined span{display:grid;gap:4px;padding:9px;border-left:1px solid var(--shd-border)}.combined span:first-child{border-left:0}.combined b{color:var(--shd-info);font-size:20px}
small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
.ability-results{display:grid;border:1px solid var(--shd-border)}.ability-results>div{display:grid;grid-template-columns:minmax(140px,1fr) repeat(3,auto);gap:var(--shd-gap-sm);padding:7px 8px;border-top:1px solid var(--shd-border);align-items:center}.ability-results>div:first-child{border-top:0}.ability-results>div.disabled{opacity:.55}.ability-results span{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}
.missing{display:grid;gap:4px;padding:10px;border:1px solid var(--shd-danger);border-left-width:2px;background:#1d1212;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.missing strong{color:var(--shd-danger);text-transform:uppercase}.missing ul{margin:2px 0 0;padding-left:18px}
.missing{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:3px 8px;padding:7px 8px;border:1px solid var(--shd-danger);border-left-width:2px;background:#171111}.missing strong{color:var(--shd-danger);font-size:9px;letter-spacing:.8px;white-space:nowrap}.missing>span{overflow:hidden;color:var(--shd-text);font-size:var(--shd-type-telemetry);font-weight:800;text-overflow:ellipsis;white-space:nowrap}.missing>small{grid-column:1/-1;color:var(--shd-text-dim);font-size:9px;letter-spacing:.35px}
footer{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:.6px}
@media(max-width:700px){header,.combined{display:grid;grid-template-columns:1fr}.combined span{border-left:0;border-top:1px solid var(--shd-border)}.combined span:first-child{border-top:0}.ability-results>div{grid-template-columns:1fr 1fr}.ability-results strong{grid-column:1/-1}}
@media(max-width:700px){header,.combined{display:grid;grid-template-columns:1fr}.skill-calculation.incomplete>header{display:flex}.combined span{border-left:0;border-top:1px solid var(--shd-border)}.combined span:first-child{border-top:0}.ability-results>div{grid-template-columns:1fr 1fr}.ability-results strong{grid-column:1/-1}}
</style>
@@ -28,9 +28,19 @@
revisionLabel?: string;
};
let { result }: { result: WeaponCalculationResult } = $props();
let {
result,
resolverHint = 'Complete the required build configuration.'
}: {
result: WeaponCalculationResult;
resolverHint?: string;
} = $props();
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });
function format(value: number | undefined) { return value === undefined ? '—' : number.format(value); }
const loadoutRequirement = (item: string) =>
/weapon$| gear$|OS Protocol|Specialization focus|focus abilities|Skill Chips?$/i.test(item);
const systemRequirements = $derived((result.missing ?? []).filter((item) => !loadoutRequirement(item)));
const loadoutRequirementCount = $derived((result.missing ?? []).length - systemRequirements.length);
</script>
<article class:incomplete={result.status === 'insufficient'} class="weapon-calculation" aria-labelledby={`${result.weaponLabel}-calculation`} aria-live="polite">
@@ -39,7 +49,11 @@
<b>{result.status === 'complete' ? 'CALCULATED' : 'INSUFFICIENT DATA'}</b>
</header>
{#if result.status === 'insufficient'}
<div class="missing"><strong>Calculation unavailable</strong><span>Complete the following required data:</span><ul>{#each result.missing ?? [] as item}<li>{item}</li>{/each}</ul></div>
<div class="missing" role="status">
<strong>OUTPUT BLOCKED</strong>
<span>{systemRequirements[0] ?? `${loadoutRequirementCount} loadout requirement${loadoutRequirementCount === 1 ? '' : 's'}`}</span>
<small>{loadoutRequirementCount ? `${loadoutRequirementCount} loadout requirement${loadoutRequirementCount === 1 ? '' : 's'} remain. ${resolverHint}` : resolverHint}</small>
</div>
{:else}
<div class="damage-grid" aria-label={`${result.weaponName} damage estimates`}>
<span><small>BODY HIT</small><b>{format(result.bodyHit)}</b></span><span><small>CRITICAL BODY</small><b>{format(result.criticalBodyHit)}</b></span>
@@ -63,14 +77,14 @@
</div>
{/if}
{/if}
<footer><span>MODELED, BEFORE ENEMY MITIGATION</span>{#if result.revisionLabel}<span>{result.revisionLabel}</span>{/if}</footer>
{#if result.status === 'complete'}<footer><span>MODELED, BEFORE ENEMY MITIGATION</span>{#if result.revisionLabel}<span>{result.revisionLabel}</span>{/if}</footer>{/if}
</article>
<style>
.weapon-calculation{display:grid;gap:var(--shd-gap-md);padding:var(--shd-gap-md);border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-surface)}
.weapon-calculation.incomplete{border-top-color:var(--shd-danger)}header{display:flex;justify-content:space-between;gap:var(--shd-gap-md);align-items:start}header p{margin:0 0 3px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.1px}h3{margin:0;font-size:var(--shd-type-body);letter-spacing:.7px;text-transform:uppercase}header>b{padding:5px 7px;border:1px solid #74552a;background:#1d180e;color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px;white-space:nowrap}.incomplete header>b{border-color:#563734;background:#1d1212;color:var(--shd-danger)}
.damage-grid,.dps-grid{display:grid;border:1px solid var(--shd-border)}.damage-grid{grid-template-columns:repeat(2,1fr)}.dps-grid{grid-template-columns:repeat(3,1fr)}.damage-grid span,.dps-grid span{display:grid;gap:4px;padding:9px;border-left:1px solid var(--shd-border);border-top:1px solid var(--shd-border)}.damage-grid span:nth-child(odd),.dps-grid span:first-child{border-left:0}.damage-grid span:nth-child(-n+2){border-top:0}.dps-grid b{color:var(--shd-orange);font-size:20px}.damage-grid b{font-size:var(--shd-type-body)}small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.missing{display:grid;gap:4px;padding:10px;border:1px solid #563734;border-left:2px solid var(--shd-danger);background:#1d1212;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.missing strong{color:var(--shd-danger);text-transform:uppercase;font-size:var(--shd-type-telemetry);letter-spacing:.8px}.missing ul{margin:2px 0 0;padding-left:18px}.missing li+li{margin-top:2px}footer{display:flex;justify-content:space-between;gap:var(--shd-gap-sm);color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:.6px}
.weapon-calculation.incomplete{gap:6px;padding:8px;border-top-color:var(--shd-danger)}header{display:flex;justify-content:space-between;gap:var(--shd-gap-md);align-items:start}header p{margin:0 0 3px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.1px}h3{margin:0;font-size:var(--shd-type-body);letter-spacing:.7px;text-transform:uppercase}header>b{padding:5px 7px;border:1px solid #74552a;background:#1d180e;color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px;white-space:nowrap}.incomplete header h3{font-size:10px}.incomplete header>b{padding:3px 5px;border-color:#563734;background:#1d1212;color:var(--shd-danger);font-size:8px}
.damage-grid,.dps-grid{display:grid;border:1px solid var(--shd-border)}.damage-grid{grid-template-columns:repeat(2,1fr)}.dps-grid{grid-template-columns:repeat(3,1fr)}.damage-grid span,.dps-grid span{display:grid;gap:4px;padding:9px;border-left:1px solid var(--shd-border);border-top:1px solid var(--shd-border)}.damage-grid span:nth-child(odd),.dps-grid span:first-child{border-left:0}.damage-grid span:nth-child(-n+2){border-top:0}.dps-grid b{color:var(--shd-orange);font-size:20px}.damage-grid b{font-size:var(--shd-type-body)}small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.missing{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:3px 8px;padding:7px 8px;border:1px solid #563734;border-left:2px solid var(--shd-danger);background:#171111;color:var(--shd-text-muted)}.missing strong{color:var(--shd-danger);font-size:9px;letter-spacing:.8px;white-space:nowrap}.missing>span{overflow:hidden;color:var(--shd-text);font-size:var(--shd-type-telemetry);font-weight:800;text-overflow:ellipsis;white-space:nowrap}.missing>small{grid-column:1/-1;color:var(--shd-text-dim);font-size:9px;font-weight:700;letter-spacing:.35px}footer{display:flex;justify-content:space-between;gap:var(--shd-gap-sm);color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:.6px}
.stack-telemetry{display:grid;border:1px solid var(--shd-model-border);background:var(--shd-model-surface)}.stack-telemetry>header{display:flex;align-items:center;justify-content:space-between;padding:6px 8px;border-bottom:1px solid var(--shd-model-border);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.stack-telemetry>header span{color:var(--shd-info)}.stack-telemetry>header b{color:var(--shd-text-muted)}.stack-telemetry>div{display:grid;grid-template-columns:minmax(120px,1.2fr) .45fr .7fr .8fr .9fr .85fr .8fr;gap:7px;align-items:center;padding:7px 8px;border-top:1px solid var(--shd-border-soft);font-size:var(--shd-type-telemetry)}.stack-telemetry>div:first-of-type{border-top:0}.stack-telemetry>div>span,.stack-telemetry>div>small,.stack-telemetry>div>em,.stack-telemetry>div>b{font-size:inherit;font-style:normal;letter-spacing:.5px}.stack-telemetry>div>span{color:var(--shd-orange);font-weight:900}.stack-telemetry>div>em{color:var(--shd-text-muted)}.stack-telemetry>div>b{color:var(--shd-danger);text-align:right}.stack-telemetry>div>b.maintained{color:var(--shd-success)}
@media(max-width:820px){.stack-telemetry>div{grid-template-columns:1fr 1fr}.stack-telemetry>div>strong{grid-column:1/-1}.stack-telemetry>div>b{text-align:left}}
@media(max-width:620px){header{display:grid}.dps-grid{grid-template-columns:1fr}.dps-grid span{border-left:0;border-top:1px solid var(--shd-border)}.dps-grid span:first-child{border-top:0}footer{display:grid}.damage-grid span{min-height:44px}}
@media(max-width:620px){header{display:grid}.weapon-calculation.incomplete>header{display:flex}.dps-grid{grid-template-columns:1fr}.dps-grid span{border-left:0;border-top:1px solid var(--shd-border)}.dps-grid span:first-child{border-top:0}footer{display:grid}.damage-grid span{min-height:44px}}
</style>
@@ -7,11 +7,21 @@
import type { AiMechanicContext } from '$lib/ai/mechanic-authoring';
import {
blankConditionalMechanicDraft,
currentHealthPercentBandPredicate,
eventFieldComparisonPredicate,
parseConditionalMechanicDraft,
readCurrentHealthPercentBand,
readEventFieldComparison,
readStatePoolThreshold,
statePoolThresholdPredicate,
type ConditionalMechanicClauseDraft,
type ConditionalMechanicDraftV1,
type ConditionalMechanicOutcomeDraft,
type ConditionalMechanicPoolDraft
type ConditionalMechanicPoolDraft,
type EditablePredicateScalar,
type EventFieldComparison,
type PredicateComparisonOperator,
type StatePoolThresholdComparison
} from '$lib/calculations/mechanic-editor';
import {
formatMechanicProgram,
@@ -59,6 +69,11 @@
let recipeCount = $state(9);
let recipeWindow = $state(3);
let recipeDistance = $state(10);
let recipeHealthBands = $state([
{ thresholdPercent: 25, outcomeKey: '15-percent-reduction' },
{ thresholdPercent: 50, outcomeKey: '12-percent-reduction' },
{ thresholdPercent: 75, outcomeKey: '9-percent-reduction' }
]);
let replaceConfirmation = $state(false);
let valueModelOverrides = $state<Record<string, OutcomeValueModel>>({});
@@ -90,17 +105,32 @@
{ value: 'direct_damage', label: 'DAMAGE' },
{ value: 'restoration', label: 'RESTORE' },
{ value: 'resource_cost', label: 'COST' },
{ value: 'weapon_reload', label: 'WEAPON RELOAD' },
{ value: 'marker', label: 'MARKER' }
];
const scopeOptions = $derived([
{ value: 'self', label: 'SELF' },
{ value: 'equipped_weapon', label: 'WEAPON' },
{ value: 'inactive_weapon', label: 'INACTIVE WEAPON' },
{ value: 'target', label: 'TARGET' },
{ value: 'allies', label: 'ALLIES' },
{ value: 'self_and_allies', label: 'SELF + ALLIES' },
...(allowAssignedAbility ? [{ value: 'assigned_ability', label: 'ABILITY' }] : []),
{ value: 'all_skills', label: 'ALL SKILLS' }
]);
const predicateOperatorOptions = [
{ value: 'eq', label: '=' },
{ value: 'ne', label: '≠' },
{ value: 'gt', label: '>' },
{ value: 'gte', label: '≥' },
{ value: 'lt', label: '<' },
{ value: 'lte', label: '≤' }
];
const predicateScalarTypeOptions = [
{ value: 'string', label: 'TEXT' },
{ value: 'number', label: 'NUMBER' },
{ value: 'boolean', label: 'BOOLEAN' }
];
const slug = (value: string) => value.toLocaleLowerCase()
.replace(/[^a-z0-9]+/g, '-')
@@ -114,10 +144,119 @@
if (recipeKey === 'consecutive_same_target_hits') return { hitsRequired: recipeCount };
if (recipeKey === 'continuous_firing_duration') return { seconds: recipeWindow };
if (recipeKey === 'target_hit_within_distance' || recipeKey === 'within_distance') return { meters: recipeDistance };
if (recipeKey === 'health_threshold_bands') return { bands: [{ thresholdPercent: 50, outcomeKey: 'active' }] };
if (recipeKey === 'health_threshold_bands') return { bands: recipeHealthBands };
return {};
}
function conditionModel(clause: ConditionalMechanicClauseDraft) {
if (!clause.if || clause.if.kind === 'true') return 'always';
if (readCurrentHealthPercentBand(clause.if)) return 'current_health_band';
if (readEventFieldComparison(clause.if)) return 'event_field';
if (readStatePoolThreshold(clause.if)) return 'state_pool_threshold';
return 'advanced';
}
function conditionOptions(clause: ConditionalMechanicClauseDraft) {
const options = [
{ value: 'always', label: 'ALWAYS' },
{ value: 'current_health_band', label: 'HEALTH BAND' },
{ value: 'event_field', label: 'EVENT FIELD' },
{ value: 'state_pool_threshold', label: 'STATE THRESHOLD', disabled: !rule.pools.length }
];
return conditionModel(clause) === 'advanced'
? [
...options.map((option) => ({ ...option, disabled: true })),
{ value: 'advanced', label: 'ADVANCED', disabled: true }
]
: options;
}
function setConditionModel(clauseIndex: number, model: string) {
if (model === 'always') updateClause(clauseIndex, { if: { kind: 'true' } });
if (model === 'current_health_band') {
updateClause(clauseIndex, {
if: currentHealthPercentBandPredicate({ maximum: 75 })
});
}
if (model === 'event_field') {
updateClause(clauseIndex, {
if: eventFieldComparisonPredicate({
path: 'values.state',
operator: 'eq',
value: 'moving'
})
});
}
if (model === 'state_pool_threshold' && rule.pools[0]) {
updateClause(clauseIndex, {
if: statePoolThresholdPredicate({
pool: rule.pools[0].key,
operator: 'gt',
value: 0
})
});
}
}
function updateHealthBand(
clauseIndex: number,
boundary: 'minimum' | 'maximum',
rawValue: string
) {
const current = readCurrentHealthPercentBand(rule.clauses[clauseIndex].if) ?? { maximum: 75 };
const numeric = rawValue === '' ? undefined : Number(rawValue);
const next = {
...(boundary === 'minimum'
? numeric === undefined ? {} : { minimum: numeric }
: current.minimum === undefined ? {} : { minimum: current.minimum }),
maximum: boundary === 'maximum' ? numeric ?? current.maximum : current.maximum
};
updateClause(clauseIndex, { if: currentHealthPercentBandPredicate(next) });
}
function updateEventFieldComparison(
clauseIndex: number,
patch: Partial<EventFieldComparison>
) {
const current = readEventFieldComparison(rule.clauses[clauseIndex].if) ?? {
path: 'values.state',
operator: 'eq',
value: 'moving'
} as const;
updateClause(clauseIndex, {
if: eventFieldComparisonPredicate({ ...current, ...patch })
});
}
function predicateScalarType(value: EditablePredicateScalar) {
return typeof value;
}
function setEventFieldScalarType(clauseIndex: number, type: string) {
const current = readEventFieldComparison(rule.clauses[clauseIndex].if);
if (!current) return;
const value: EditablePredicateScalar = type === 'number'
? (typeof current.value === 'number' ? current.value : 0)
: type === 'boolean'
? (typeof current.value === 'boolean' ? current.value : true)
: typeof current.value === 'string' ? current.value : String(current.value);
updateEventFieldComparison(clauseIndex, { value });
}
function updateStatePoolThreshold(
clauseIndex: number,
patch: Partial<StatePoolThresholdComparison>
) {
const current = readStatePoolThreshold(rule.clauses[clauseIndex].if) ?? {
pool: rule.pools[0]?.key ?? '',
operator: 'gt',
value: 0
} as const;
updateClause(clauseIndex, {
if: statePoolThresholdPredicate({ ...current, ...patch })
});
}
function applyRecipe(replace = false) {
if (!recipeKey) return;
const selectedRecipeKey = recipeKey;
@@ -647,6 +786,19 @@
{#if ['target_hit_within_distance', 'within_distance'].includes(recipeKey)}
<label class="field">METERS<input type="number" min="0" step="any" bind:value={recipeDistance}/></label>
{/if}
{#if recipeKey === 'health_threshold_bands'}
<div class="health-band-config">
<p>Define strict “below Health” bands from strongest to weakest. Exact thresholds enter the next weaker band.</p>
{#each recipeHealthBands as band, index}
<div>
<label class="field">BELOW · HEALTH %<input type="number" min="0.01" max="100" step="any" bind:value={band.thresholdPercent}/></label>
<label class="field">BAND LABEL<input maxlength="80" bind:value={band.outcomeKey}/></label>
<button type="button" class="remove" onclick={() => recipeHealthBands = recipeHealthBands.filter((_, current) => current !== index)} disabled={recipeHealthBands.length === 1}>REMOVE</button>
</div>
{/each}
<button type="button" class="secondary compact-action" onclick={() => recipeHealthBands = [...recipeHealthBands,{thresholdPercent:Math.min(100,(recipeHealthBands.at(-1)?.thresholdPercent ?? 0) + 25),outcomeKey:`health-band-${recipeHealthBands.length + 1}`}]} disabled={recipeHealthBands.length >= 5}>ADD BAND +</button>
</div>
{/if}
<button type="button" class="secondary" disabled={!recipeKey} onclick={() => applyRecipe(false)}>ADD RECIPE BRANCH +</button>
{#if rule.clauses.length && recipeKey}
<button type="button" class="danger-quiet" onclick={() => replaceConfirmation = true}>REPLACE PROGRAM</button>
@@ -669,7 +821,13 @@
<label class="field">INITIAL<input type="number" value={pool.initial} oninput={(event) => updatePool(index,{initial:Number(event.currentTarget.value)})}/></label>
<label class="field">MAX<input type="number" min="1" value={pool.max ?? 1} oninput={(event) => updatePool(index,{max:Number(event.currentTarget.value)})}/></label>
<label class="field">BEST CASE<input type="number" value={pool.bestCaseValue ?? pool.max ?? 1} oninput={(event) => updatePool(index,{bestCaseValue:Number(event.currentTarget.value)})}/></label>
<label class="field">EXPIRY · SECONDS<input type="number" min="0.01" step="any" value={pool.expiresAfterSeconds ?? ''} oninput={(event) => updatePool(index,{expiresAfterSeconds:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
{#if pool.expiresAfterSecondsVariants}
{#each valueColumns as column}
<label class="field">EXPIRY · {column.label}<input type="number" min="0.01" step="any" value={pool.expiresAfterSecondsVariants?.[column.variantKey] ?? ''} oninput={(event) => updatePool(index,{expiresAfterSecondsVariants:{...pool.expiresAfterSecondsVariants,[column.variantKey]:Number(event.currentTarget.value)}})}/></label>
{/each}
{:else}
<label class="field">EXPIRY · SECONDS<input type="number" min="0.01" step="any" value={pool.expiresAfterSeconds ?? ''} oninput={(event) => updatePool(index,{expiresAfterSeconds:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
{/if}
<button type="button" class="remove" onclick={() => setRule({...rule,pools:rule.pools.filter((_,current) => current !== index)})}>REMOVE</button>
</div>
{:else}
@@ -679,6 +837,9 @@
<div class="branch-stack">
{#each rule.clauses as clause, clauseIndex}
{@const conditionBand = readCurrentHealthPercentBand(clause.if)}
{@const eventFieldCondition = readEventFieldComparison(clause.if)}
{@const statePoolCondition = readStatePoolThreshold(clause.if)}
<section class="branch">
<header>
<div><span>{String(clauseIndex + 1).padStart(2, '0')}</span><label class="field">BRANCH LABEL<input value={clause.label ?? ''} oninput={(event) => updateClause(clauseIndex,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label></div>
@@ -689,6 +850,75 @@
<div class="branch-readout"><span>MATCH POLICY</span><strong>{reducerItems.find((item) => item.id === clause.reducer.kind)?.name ?? clause.reducer.kind}</strong></div>
<label class="field">COOLDOWN · SECONDS<input type="number" min="0.01" step="any" value={clause.cooldownSeconds ?? ''} oninput={(event) => updateClause(clauseIndex,{cooldownSeconds:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
</div>
<div class="condition-controls">
<SegmentedChoice
legend="BRANCH CONDITION"
name={`${idPrefix}-condition-${clauseIndex}`}
options={conditionOptions(clause)}
value={conditionModel(clause)}
onchange={(values) => setConditionModel(clauseIndex,values[0])}
/>
{#if conditionBand}
<div class="health-boundaries">
<label class="field">FROM · HEALTH %<input type="number" min="0" max="100" step="any" value={conditionBand.minimum ?? ''} placeholder="0" oninput={(event) => updateHealthBand(clauseIndex,'minimum',event.currentTarget.value)}/><small>Inclusive; leave blank for zero.</small></label>
<label class="field">BELOW · HEALTH %<input type="number" min="0.01" max="100" step="any" value={conditionBand.maximum} oninput={(event) => updateHealthBand(clauseIndex,'maximum',event.currentTarget.value)}/><small>Exclusive; exact values do not match.</small></label>
</div>
{:else if eventFieldCondition}
<div class="predicate-fields event-predicate-fields">
<label class="field">EVENT FIELD PATH<input value={eventFieldCondition.path} placeholder="values.state" oninput={(event) => updateEventFieldComparison(clauseIndex,{path:event.currentTarget.value})}/></label>
<SegmentedChoice
legend="COMPARISON"
name={`${idPrefix}-event-operator-${clauseIndex}`}
options={predicateOperatorOptions}
value={eventFieldCondition.operator}
columns={6}
onchange={(values) => updateEventFieldComparison(clauseIndex,{operator:values[0] as PredicateComparisonOperator})}
/>
<SegmentedChoice
legend="VALUE TYPE"
name={`${idPrefix}-event-value-type-${clauseIndex}`}
options={predicateScalarTypeOptions}
value={predicateScalarType(eventFieldCondition.value)}
onchange={(values) => setEventFieldScalarType(clauseIndex,values[0])}
/>
{#if typeof eventFieldCondition.value === 'number'}
<label class="field">COMPARE VALUE<input type="number" step="any" value={eventFieldCondition.value} oninput={(event) => updateEventFieldComparison(clauseIndex,{value:Number(event.currentTarget.value)})}/></label>
{:else if typeof eventFieldCondition.value === 'boolean'}
<SegmentedChoice
legend="COMPARE VALUE"
name={`${idPrefix}-event-boolean-${clauseIndex}`}
options={[{value:'true',label:'TRUE'},{value:'false',label:'FALSE'}]}
value={String(eventFieldCondition.value)}
onchange={(values) => updateEventFieldComparison(clauseIndex,{value:values[0] === 'true'})}
/>
{:else}
<label class="field">COMPARE VALUE<input value={eventFieldCondition.value} placeholder="moving" oninput={(event) => updateEventFieldComparison(clauseIndex,{value:event.currentTarget.value})}/></label>
{/if}
</div>
{:else if statePoolCondition}
<div class="predicate-fields state-predicate-fields">
<div class="field"><span>STATE POOL</span><SearchableAssignment
items={rule.pools.map((pool) => ({id:pool.key,name:pool.label}))}
mode="single"
compact
subjectLabel="STATE POOL"
value={statePoolCondition.pool}
onvaluechange={(value) => updateStatePoolThreshold(clauseIndex,{pool:value})}
/></div>
<SegmentedChoice
legend="COMPARISON"
name={`${idPrefix}-state-operator-${clauseIndex}`}
options={predicateOperatorOptions}
value={statePoolCondition.operator}
columns={6}
onchange={(values) => updateStatePoolThreshold(clauseIndex,{operator:values[0] as PredicateComparisonOperator})}
/>
<label class="field">THRESHOLD<input type="number" step="any" value={statePoolCondition.value} oninput={(event) => updateStatePoolThreshold(clauseIndex,{value:Number(event.currentTarget.value)})}/></label>
</div>
{:else if conditionModel(clause) === 'advanced'}
<p class="advanced-condition-note">This saved condition remains active and is preserved unchanged. Use the generated mechanic preview to inspect it.</p>
{/if}
</div>
<div class="reducer-controls">
<SegmentedChoice
legend="MATCH POLICY"
@@ -760,10 +990,28 @@
<label class="field">DESCRIPTION SLOT<input type="number" min="1" step="1" value={outcome.descriptionSlot ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{descriptionSlot:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
</div>
<SegmentedChoice legend="TARGET SCOPE" name={`${idPrefix}-scope-${clauseIndex}-${outcomeIndex}`} options={scopeOptions} value={outcome.scope} columns={scopeOptions.length > 4 ? 4 : scopeOptions.length} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{scope:values[0] as MechanicOutcomeScope})}/>
{#if outcome.scope === 'allies' || outcome.scope === 'self_and_allies' || outcome.scope === 'target'}
<div class="delivery-controls">
<SegmentedChoice
legend="AREA TARGETING"
name={`${idPrefix}-targeting-${clauseIndex}-${outcomeIndex}`}
options={[{value:'none',label:'NONE'},{value:'allies',label:'ALLIES IN RADIUS'},{value:'enemies',label:'ENEMIES IN RADIUS'}]}
value={outcome.targeting?.population ?? 'none'}
onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{
targeting: values[0] === 'none'
? undefined
: {kind:'within_radius',population:values[0] as 'allies'|'enemies',radiusMeters:outcome.targeting?.radiusMeters ?? 20}
})}
/>
{#if outcome.targeting}
<label class="field">RADIUS (METERS)<input type="number" min="0.01" step="any" value={outcome.targeting.radiusMeters} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{targeting:{...outcome.targeting!,radiusMeters:Number(event.currentTarget.value)}})}/></label>
{/if}
</div>
{/if}
{#if outcome.kind === 'attribute_modifier'}
<SearchableAssignment items={attributes} mode="single" compact subjectLabel="ATTRIBUTE" bind:value={outcome.attributeId}/>
{:else if outcome.kind === 'restoration' || outcome.kind === 'resource_cost'}
<SegmentedChoice legend="RESOURCE" name={`${idPrefix}-resource-${clauseIndex}-${outcomeIndex}`} options={[{value:'health',label:'HEALTH'},{value:'armor',label:'ARMOR'},{value:'ammo',label:'AMMO'}]} value={outcome.resource ?? 'health'} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{resource:values[0] as 'health'|'armor'|'ammo'})}/>
<SegmentedChoice legend="RESOURCE" name={`${idPrefix}-resource-${clauseIndex}-${outcomeIndex}`} options={[{value:'health',label:'HEALTH'},{value:'armor',label:'ARMOR'},{value:'ammo',label:'AMMO'},{value:'signature_ability_charge',label:'SIGNATURE CHARGE'}]} value={outcome.resource ?? 'health'} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{resource:values[0] as 'health'|'armor'|'ammo'|'signature_ability_charge'})}/>
{/if}
{#if outcome.kind === 'restoration'}
<div class="delivery-controls">
@@ -876,6 +1124,9 @@
.scenario-row{display:grid;grid-template-columns:minmax(170px,1fr) minmax(260px,1.25fr) minmax(220px,1.3fr) repeat(4,minmax(85px,.45fr)) auto;align-items:end;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border-soft)}
.scenario-row>.field{margin:0}.scenario-row>.description-field{min-width:0}
.recipe-grid{display:grid;grid-template-columns:minmax(260px,1fr) repeat(3,minmax(110px,.35fr)) auto auto;align-items:end;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)}
.health-band-config{grid-column:1/-1;display:grid;gap:var(--shd-gap-xs);padding:var(--shd-gap-sm);border:1px solid var(--shd-model-border);background:var(--shd-model-surface)}
.health-band-config>p,.advanced-condition-note{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
.health-band-config>div{display:grid;grid-template-columns:minmax(150px,.5fr) minmax(220px,1fr) auto;align-items:end;gap:var(--shd-gap-sm)}
.recipe-grid>.field,.pool-row>.field,.branch-grid>.field,.outcome-grid>.field,.variant-grid>.field{margin:0}
.replace-confirm{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm);padding:9px;border-top:1px solid var(--shd-danger);background:color-mix(in srgb,var(--shd-danger) 10%,var(--shd-surface))}
.replace-confirm p{display:grid;gap:2px;margin:0}.replace-confirm span{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.replace-confirm>div{display:flex;gap:7px}
@@ -883,6 +1134,12 @@
.branch-stack{display:grid;gap:var(--shd-gap-sm)}
.branch>header>div{display:flex;align-items:end;gap:9px}.branch>header>div>span{align-self:center;color:var(--shd-orange);font:900 var(--shd-type-telemetry)/1 var(--shd-font-mono)}.branch>header .field{min-width:min(360px,55vw);margin:0}
.branch-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr)) minmax(160px,.45fr);align-items:end;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)}
.condition-controls{display:grid;grid-template-columns:minmax(260px,.7fr) minmax(320px,1.3fr);align-items:end;gap:var(--shd-gap-sm);padding:0 var(--shd-gap-sm) var(--shd-gap-sm)}
.health-boundaries{display:grid;grid-template-columns:repeat(2,minmax(150px,1fr));gap:var(--shd-gap-sm)}
.health-boundaries .field small{display:block;margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.3}
.predicate-fields{display:grid;grid-template-columns:minmax(170px,1fr) minmax(190px,.8fr);align-items:end;gap:var(--shd-gap-sm)}
.event-predicate-fields{grid-template-columns:minmax(180px,1fr) minmax(190px,.85fr) minmax(180px,.75fr) minmax(150px,.75fr)}
.predicate-fields>.field{margin:0}
.field>span{display:block;margin-bottom:5px;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
.branch-readout{display:grid;align-content:center;gap:3px;min-height:44px;padding:7px 9px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}.branch-readout span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.branch-readout strong{font-size:var(--shd-type-supporting)}
.reducer-controls{display:grid;grid-template-columns:minmax(0,1fr) repeat(2,minmax(130px,.25fr));align-items:end;gap:var(--shd-gap-sm);padding:0 var(--shd-gap-sm) var(--shd-gap-sm)}
@@ -899,7 +1156,7 @@
.compact-action{min-height:36px}.add-branch{min-height:44px}.empty-state{padding:10px}.script-preview{display:grid;grid-template-columns:minmax(190px,.28fr) minmax(0,1fr);gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-border);border-top:2px solid var(--shd-success);background:var(--shd-surface)}
.script-preview>div{display:grid;align-content:start;gap:3px}.script-preview>div span{color:var(--shd-success);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
@media(max-width:1300px){.scenario-row{grid-template-columns:repeat(2,minmax(0,1fr))}.scenario-row>.description-field{grid-column:1/-1}}
@media(max-width:1000px){.recipe-grid,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.delivery-controls,.script-preview{grid-template-columns:1fr 1fr}.recipe-grid>:first-child,.script-preview>:last-child{grid-column:1/-1}.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:1/-1}}
@media(max-width:620px){.mechanic-heading,.recipe-panel>header,.scenario-panel>header,.state-panel>header,.branch>header,.outcome-heading,.replace-confirm,.outcome-footer{align-items:stretch;flex-direction:column}.recipe-grid,.scenario-row,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.delivery-controls,.script-preview{grid-template-columns:1fr}.recipe-grid>:first-child,.script-preview>:last-child,.scenario-row>.description-field,.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:auto}.branch>header>div{align-items:stretch;flex-direction:column}.branch>header .field{min-width:0}.remove,.danger-quiet,.compact-action,.add-branch{width:100%;min-height:44px}}
@media(max-width:1000px){.recipe-grid,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.delivery-controls,.script-preview{grid-template-columns:1fr 1fr}.recipe-grid>:first-child,.script-preview>:last-child{grid-column:1/-1}.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:1/-1}.condition-controls{grid-template-columns:1fr}.event-predicate-fields{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:620px){.mechanic-heading,.recipe-panel>header,.scenario-panel>header,.state-panel>header,.branch>header,.outcome-heading,.replace-confirm,.outcome-footer{align-items:stretch;flex-direction:column}.recipe-grid,.scenario-row,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.delivery-controls,.script-preview,.health-band-config>div,.health-boundaries,.predicate-fields,.event-predicate-fields{grid-template-columns:1fr}.recipe-grid>:first-child,.script-preview>:last-child,.scenario-row>.description-field,.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:auto}.branch>header>div{align-items:stretch;flex-direction:column}.branch>header .field{min-width:0}.remove,.danger-quiet,.compact-action,.add-branch{width:100%;min-height:44px}}
@media(prefers-reduced-motion:reduce){.remove,.danger-quiet{transition:none}}
</style>
@@ -78,7 +78,7 @@ describe('AI mechanic authoring prompt', () => {
recipeKeys: []
}));
expect(AI_MECHANIC_PROMPT_VERSION).toBe(5);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(6);
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
expect.arrayContaining(['delivery', 'durationSeconds'])
);
@@ -115,7 +115,7 @@ describe('AI mechanic authoring prompt', () => {
expect.stringContaining('Do not replace continuous delivery with a periodic reducer')
]));
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
'Prompt contract version: 5'
'Prompt contract version: 6'
);
const advertised = structuredClone(
@@ -157,6 +157,33 @@ describe('AI mechanic authoring prompt', () => {
expect(parsed.ok).toBe(true);
});
it('advertises authoritative current Health percentage with strict band semantics', () => {
const input = JSON.parse(buildAiMechanicAuthoringInput({
request,
attributes: [],
events: [{ key: 'tick', label: 'Simulation tick' }],
actions: [],
recipeKeys: ['health_threshold_bands']
}));
expect(input.canonicalCatalog.grammar.combatReferences.currentHealthPercent)
.toMatchObject({
label: 'Current Health percentage',
description: expect.stringContaining('0 through 100')
});
expect(input.canonicalCatalog.grammar.referenceShapes).toContainEqual({
ref: {
source: 'combat',
path: 'currentHealthPercent',
timing: 'current'
}
});
expect(input.examplesAndRules).toEqual(expect.arrayContaining([
expect.stringContaining('operator:"lt"'),
expect.stringContaining('<25, >=25 and <50, and >=50 and <75')
]));
});
it('advertises Precise Trauma through target expiry without inventing target tick iteration', () => {
const skillDamageId = 'skill-damage-catalog-id';
const input = JSON.parse(buildAiMechanicAuthoringInput({
+11 -5
View File
@@ -1,8 +1,9 @@
import type { AiMechanicAuthoringRequest } from '$lib/ai/mechanic-authoring';
import { CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES } from '$lib/calculations/mechanic-editor';
import { MECHANIC_COMBAT_REFERENCE_REGISTRY } from '$lib/calculations/mechanic';
import type { AiMechanicAttribute } from './mechanic-authoring-context';
export const AI_MECHANIC_PROMPT_VERSION = 5;
export const AI_MECHANIC_PROMPT_VERSION = 6;
const mechanicsGrammar = {
version: 1,
@@ -17,9 +18,9 @@ const mechanicsGrammar = {
required: ['key', 'label', 'stateKind', 'scope', 'initial'],
stateKind: ['flag', 'count', 'stack', 'duration', 'scalar', 'target_mark', 'resource'],
scope: ['agent', 'target'],
optional: ['min', 'max', 'bestCaseValue', 'expiresAfterSeconds'],
optional: ['min', 'max', 'bestCaseValue', 'expiresAfterSeconds', 'expiresAfterSecondsVariants'],
expirationSemantics:
'expiresAfterSeconds is one timer per pool entity. Every write resets that entity timer, and expiry resets only that entity to the pool initial value.',
'expiresAfterSeconds is one timer per pool entity. Use expiresAfterSecondsVariants instead when each source variant has a different duration; define every requested variant exactly once. Every write resets that entity timer, and expiry resets only that entity to the pool initial value.',
targetScopeSemantics:
'A target-scoped pool stores one independent value and expiry timer per resolved entity. Use mutation.entity event_target when a weapon-hit mechanic binds state to the enemy that was hit.'
},
@@ -43,11 +44,13 @@ const mechanicsGrammar = {
mutationKinds: ['none', 'add', 'set', 'consume', 'derive', 'clear', 'transfer'],
entitySelectors: ['agent', 'event_actor', 'event_target', 'event_field'],
valueReferences: ['event', 'scenario', 'combat', 'equipment', 'attribute', 'reducer', 'pool'],
numericExpressionOperators: ['add', 'subtract', 'multiply', 'divide', 'min', 'max', 'clamp'],
combatReferences: MECHANIC_COMBAT_REFERENCE_REGISTRY,
numericExpressionOperators: ['add', 'subtract', 'multiply', 'divide', 'min', 'max', 'clamp', 'floor'],
referenceShapes: [
{ ref: { source: 'event', path: 'values.distanceMeters' } },
{ ref: { source: 'scenario', key: 'input-key' } },
{ ref: { source: 'combat', path: 'currentHealth', timing: 'current' } },
{ ref: { source: 'combat', path: 'currentHealthPercent', timing: 'current' } },
{ ref: { source: 'equipment', path: 'protocolTalentUpgrades' } },
{ ref: { source: 'attribute', attributeId: 'replace-with-an-exact-catalog-attribute-id' } },
{ ref: { source: 'reducer' } },
@@ -56,7 +59,8 @@ const mechanicsGrammar = {
expressionShapes: [
{ op: 'multiply', left: 3, right: { ref: { source: 'pool', pool: 'stacks' } } },
{ op: 'min', left: { ref: { source: 'reducer' } }, right: 5 },
{ op: 'clamp', value: { ref: { source: 'event', path: 'values.enemyCount' } }, min: 0, max: 5 }
{ op: 'clamp', value: { ref: { source: 'event', path: 'values.enemyCount' } }, min: 0, max: 5 },
{ op: 'floor', value: { op: 'divide', left: { ref: { source: 'event', path: 'values.distanceMeters' } }, right: 5 } }
],
predicateShapes: [
{ kind: 'true' },
@@ -339,6 +343,8 @@ export function buildAiMechanicAuthoringInput(input: {
'Percent values use the numeric percentage shown in game, such as 3 for +3%, rather than 0.03.',
'Live Attribute references must use {source:"attribute",attributeId:"<exact supplied catalog ID>"}. Never use an Attribute name or invent an ID.',
'An Attribute whose catalog unit is % resolves to its displayed percentage number, such as 20 for 20%. Divide that reference by 100 when wording applies it as a percentage multiplier, such as 240 × 20%.',
'combat.currentHealthPercent with timing:"current" resolves authoritative current Health divided by resolved maximum Health on a 0–100 scale. Use operator:"lt" for exact “below” wording.',
'Health threshold bands must be mutually exclusive. For thresholds 25, 50, and 75, use <25, >=25 and <50, and >=50 and <75; exact threshold values enter the next weaker band.',
'Continuous Health restoration is supported. Use kind:"restoration", resource:"health", delivery:"continuous", and a positive durationSeconds. The variant expression resolves the total restoration across that duration, not a per-second value. Do not replace continuous delivery with a periodic reducer.',
'Each continuous-restoration activation creates an independent constant-rate stream. Overlapping streams add, and restoration above the resource maximum is lost when it occurs.',
'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.',
+39 -4
View File
@@ -1,5 +1,6 @@
import { connect } from '@tursodatabase/database';
import { describe, expect, it } from 'vitest';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import {
conditionalRuleDraftFromRows,
loadConditionalRuleDraft,
@@ -174,7 +175,7 @@ describe('conditional mechanic persistence adapter', () => {
stat_attribute_id TEXT, value REAL, value_expression_json TEXT, value_basis TEXT,
value_scaling TEXT, unit TEXT, damage_basis TEXT, application_phase TEXT,
combat_timing TEXT, resource_kind TEXT, delivery_mode TEXT,
delivery_duration_seconds REAL, min_state_value REAL, max_state_value REAL,
delivery_duration_seconds REAL, targeting_json TEXT, min_state_value REAL, max_state_value REAL,
description_slot INTEGER, sort_order INTEGER, created_at TEXT, updated_at TEXT
)`);
@@ -189,13 +190,24 @@ describe('conditional mechanic persistence adapter', () => {
scope: 'target',
initial: 0,
min: 0,
max: 1
max: 1,
expiresAfterSecondsVariants: {
I: 12,
II: 18
}
} as ConditionalMechanicDraftV1['pools'][number] & {
expiresAfterSecondsVariants: Record<string, number>;
}],
clauses: [{
key: 'weapon-kill',
label: 'Weapon kill',
event: 'enemy_killed',
if: { kind: 'true' },
if: {
kind: 'compare',
left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' },
operator: 'lt',
right: 75
},
reducer: { kind: 'none' },
mutations: [{
kind: 'set',
@@ -213,6 +225,7 @@ describe('conditional mechanic persistence adapter', () => {
basis: 'flat',
delivery: 'continuous',
durationSeconds: 3,
targeting: { kind: 'within_radius', population: 'allies', radiusMeters: 40 },
variants: {
I: { value: 240 },
II: { value: 336 }
@@ -275,6 +288,17 @@ describe('conditional mechanic persistence adapter', () => {
{ delivery_mode: 'continuous', delivery_duration_seconds: 3 },
{ delivery_mode: 'instant', delivery_duration_seconds: null }
]);
expect(await (await database.prepare(
'SELECT duration_seconds,parameters_json FROM conditional_effect_state_pools'
)).get()).toEqual({
duration_seconds: null,
parameters_json: JSON.stringify({
expiresAfterSecondsVariants: {
I: 12,
II: 18
}
})
});
const loaded = await loadConditionalRuleDraft(
database,
'talent',
@@ -284,15 +308,26 @@ describe('conditional mechanic persistence adapter', () => {
expect(loaded?.clauses[0].outcomes[0]).toMatchObject({
delivery: 'continuous',
durationSeconds: 3,
targeting: { kind: 'within_radius', population: 'allies', radiusMeters: 40 },
variants: {
I: { value: 240 },
II: { value: 336 }
}
});
expect(loaded?.clauses[0].if).toEqual({
kind: 'compare',
left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' },
operator: 'lt',
right: 75
});
expect(loaded?.pools[0]).toMatchObject({
key: 'marked-target',
stateKind: 'target_mark',
scope: 'target'
scope: 'target',
expiresAfterSecondsVariants: {
I: 12,
II: 18
}
});
expect(loaded?.clauses[0].mutations).toEqual([{
kind: 'set',
+44 -15
View File
@@ -37,9 +37,11 @@ const applicationKind = (outcome: ConditionalMechanicOutcomeDraft) => {
if (outcome.kind === 'attribute_modifier') return 'active_modifier';
if (outcome.kind === 'direct_damage') return 'direct_damage';
if (outcome.kind === 'resource_cost') return 'resource_cost';
if (outcome.kind === 'weapon_reload') return 'weapon_reload';
if (outcome.kind === 'marker') return 'marker';
if (outcome.resource === 'armor') return 'restore_armor';
if (outcome.resource === 'ammo') return 'restore_ammo';
if (outcome.resource === 'signature_ability_charge') return 'restore_signature_ability_charge';
return 'restore_health';
};
@@ -47,17 +49,24 @@ const outcomeKind = (row: OutcomeRow): MechanicOutcome['kind'] => {
if (row.application_kind === 'active_modifier') return 'attribute_modifier';
if (row.application_kind === 'direct_damage') return 'direct_damage';
if (row.application_kind === 'resource_cost') return 'resource_cost';
if (row.application_kind === 'weapon_reload') return 'weapon_reload';
if (row.application_kind === 'marker') return 'marker';
return 'restoration';
};
const outcomeResource = (row: OutcomeRow): MechanicResource | undefined => {
if (row.resource_kind === 'health' || row.resource_kind === 'armor' || row.resource_kind === 'ammo') {
if (
row.resource_kind === 'health'
|| row.resource_kind === 'armor'
|| row.resource_kind === 'ammo'
|| row.resource_kind === 'signature_ability_charge'
) {
return row.resource_kind;
}
if (row.application_kind === 'restore_health') return 'health';
if (row.application_kind === 'restore_armor') return 'armor';
if (row.application_kind === 'restore_ammo') return 'ammo';
if (row.application_kind === 'restore_signature_ability_charge') return 'signature_ability_charge';
return undefined;
};
@@ -74,6 +83,7 @@ const outcomeMetadataColumns = [
'resource_kind',
'delivery_mode',
'delivery_duration_seconds',
'targeting_json',
'state_pool_id',
'min_state_value',
'max_state_value',
@@ -131,17 +141,25 @@ export function conditionalRuleDraftFromRows(
const pools = poolRows
.filter((row) => row.rule_id === rule.id)
.sort((left, right) => Number(left.sort_order) - Number(right.sort_order))
.map((row): ConditionalMechanicPoolDraft => ({
key: String(row.state_key),
label: String(row.label),
stateKind: String(row.state_kind) as ConditionalMechanicPoolDraft['stateKind'],
scope: String(row.state_scope) as ConditionalMechanicPoolDraft['scope'],
initial: Number(row.initial_value),
...(row.minimum_value === null || row.minimum_value === undefined ? {} : { min: Number(row.minimum_value) }),
...(row.maximum_value === null || row.maximum_value === undefined ? {} : { max: Number(row.maximum_value) }),
...(row.best_case_value === null || row.best_case_value === undefined ? {} : { bestCaseValue: Number(row.best_case_value) }),
...(row.duration_seconds === null || row.duration_seconds === undefined ? {} : { expiresAfterSeconds: Number(row.duration_seconds) })
}));
.map((row): ConditionalMechanicPoolDraft => {
const parameters = parseJson<Record<string, unknown>>(row.parameters_json, {});
const expiresAfterSecondsVariants = parameters.expiresAfterSecondsVariants;
return {
key: String(row.state_key),
label: String(row.label),
stateKind: String(row.state_kind) as ConditionalMechanicPoolDraft['stateKind'],
scope: String(row.state_scope) as ConditionalMechanicPoolDraft['scope'],
initial: Number(row.initial_value),
...(row.minimum_value === null || row.minimum_value === undefined ? {} : { min: Number(row.minimum_value) }),
...(row.maximum_value === null || row.maximum_value === undefined ? {} : { max: Number(row.maximum_value) }),
...(row.best_case_value === null || row.best_case_value === undefined ? {} : { bestCaseValue: Number(row.best_case_value) }),
...(row.duration_seconds === null || row.duration_seconds === undefined ? {} : { expiresAfterSeconds: Number(row.duration_seconds) }),
...(expiresAfterSecondsVariants && typeof expiresAfterSecondsVariants === 'object'
&& !Array.isArray(expiresAfterSecondsVariants)
? { expiresAfterSecondsVariants: expiresAfterSecondsVariants as Record<string, number> }
: {})
};
});
const poolKeyById = new Map(poolRows.map((row) => [String(row.id), String(row.state_key)]));
const rowsByClause = new Map<string, OutcomeRow[]>();
for (const row of outcomeRows.filter((item) => item.rule_id === rule.id)) {
@@ -189,6 +207,9 @@ export function conditionalRuleDraftFromRows(
...(delivery === 'continuous'
? { delivery, durationSeconds: durationSeconds as number }
: {}),
...(first.targeting_json
? { targeting: parseJson(first.targeting_json, undefined) }
: {}),
...(first.stat_attribute_id ? { attributeId: String(first.stat_attribute_id) } : {}),
...(first.state_pool_id && poolKeyById.has(String(first.state_pool_id))
? { statePool: poolKeyById.get(String(first.state_pool_id)) }
@@ -293,6 +314,11 @@ export async function saveConditionalRuleInTransaction(
const poolIds = new Map<string, string>();
for (const [index, pool] of draft.pools.entries()) {
const expiresAfterSecondsVariants = (
pool as ConditionalMechanicPoolDraft & {
expiresAfterSecondsVariants?: Record<string, number>;
}
).expiresAfterSecondsVariants;
const poolId = randomUUID();
poolIds.set(pool.key, poolId);
await (await database.prepare(`INSERT INTO conditional_effect_state_pools
@@ -313,7 +339,9 @@ export async function saveConditionalRuleInTransaction(
pool.expiresAfterSeconds ?? null,
'clear_all',
'refresh',
'{}',
JSON.stringify(expiresAfterSecondsVariants
? { expiresAfterSecondsVariants }
: {}),
index + 1,
now,
now
@@ -356,9 +384,9 @@ export async function saveConditionalRuleInTransaction(
application_kind,effect_scope,stat_attribute_id,value,value_expression_json,
value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing,
resource_kind,delivery_mode,delivery_duration_seconds,
min_state_value,max_state_value,description_slot,sort_order,
targeting_json,min_state_value,max_state_value,description_slot,sort_order,
created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
randomUUID(),
ruleId,
clauseId,
@@ -382,6 +410,7 @@ export async function saveConditionalRuleInTransaction(
outcome.resource ?? null,
outcome.delivery ?? 'instant',
outcome.durationSeconds ?? null,
outcome.targeting === undefined ? null : JSON.stringify(outcome.targeting),
outcome.minStacks ?? null,
outcome.maxStacks ?? null,
outcome.descriptionSlot ?? null,
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { isAllowedShdBuildRequestOrigin } from './request-origin';
describe('isAllowedShdBuildRequestOrigin', () => {
it('allows requests without an Origin header and same-origin requests', () => {
expect(isAllowedShdBuildRequestOrigin(null, 'http://localhost:5173')).toBe(true);
expect(
isAllowedShdBuildRequestOrigin('http://localhost:5173', 'http://localhost:5173')
).toBe(true);
});
it('allows the HTTPS SHD.build apex and subdomains', () => {
expect(isAllowedShdBuildRequestOrigin('https://shd.build', 'http://internal:3000')).toBe(true);
expect(isAllowedShdBuildRequestOrigin('https://dev.shd.build', 'http://internal:3000')).toBe(
true
);
expect(
isAllowedShdBuildRequestOrigin('https://preview.branch.shd.build', 'http://internal:3000')
).toBe(true);
});
it('rejects insecure, lookalike, unrelated, and invalid cross-origin values', () => {
expect(isAllowedShdBuildRequestOrigin('http://dev.shd.build', 'http://internal:3000')).toBe(
false
);
expect(isAllowedShdBuildRequestOrigin('https://evilshd.build', 'http://internal:3000')).toBe(
false
);
expect(isAllowedShdBuildRequestOrigin('https://example.com', 'http://internal:3000')).toBe(
false
);
expect(isAllowedShdBuildRequestOrigin('not an origin', 'http://internal:3000')).toBe(false);
});
});
+17
View File
@@ -0,0 +1,17 @@
function isShdBuildHostname(hostname: string): boolean {
return hostname === 'shd.build' || hostname.endsWith('.shd.build');
}
export function isAllowedShdBuildRequestOrigin(
requestOrigin: string | null,
serverOrigin: string
): boolean {
if (!requestOrigin || requestOrigin === serverOrigin) return true;
try {
const origin = new URL(requestOrigin);
return origin.protocol === 'https:' && isShdBuildHostname(origin.hostname);
} catch {
return false;
}
}
@@ -15,6 +15,7 @@ import {
} from '$lib/server/ai/calculation-authoring-schema';
import { generateAiCalculationDraft } from '$lib/server/ai/calculation-authoring-service';
import { AI_CALCULATION_PROMPT_VERSION } from '$lib/server/ai/calculation-authoring-prompt';
import { isAllowedShdBuildRequestOrigin } from '$lib/server/request-origin';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
@@ -26,7 +27,7 @@ export const POST: RequestHandler = async ({ cookies, request, url }) => {
'ai.calculation.generate'
);
const origin = request.headers.get('origin');
if (origin && origin !== url.origin) {
if (!isAllowedShdBuildRequestOrigin(origin, url.origin)) {
return json(
{ message: 'Cross-origin calculation authoring requests are not allowed.' },
{ status: 403 }
@@ -15,6 +15,7 @@ import {
AiMechanicRateLimitError,
reserveAiMechanicGeneration
} from '$lib/server/ai/mechanic-authoring-rate-limit';
import { isAllowedShdBuildRequestOrigin } from '$lib/server/request-origin';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
@@ -26,7 +27,7 @@ export const POST: RequestHandler = async ({ cookies, request, url }) => {
'ai.mechanic.generate'
);
const origin = request.headers.get('origin');
if (origin && origin !== url.origin) {
if (!isAllowedShdBuildRequestOrigin(origin, url.origin)) {
return json(
{ message: 'Cross-origin mechanic authoring requests are not allowed.' },
{ status: 403 }
@@ -5,6 +5,7 @@ import {
parseMechanicGapReport,
submitMechanicGapReport
} from '$lib/server/ai/mechanic-gap-report';
import { isAllowedShdBuildRequestOrigin } from '$lib/server/request-origin';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
@@ -16,7 +17,7 @@ export const POST: RequestHandler = async ({ cookies, request, url }) => {
'ai.mechanic.generate'
);
const origin = request.headers.get('origin');
if (origin && origin !== url.origin) {
if (!isAllowedShdBuildRequestOrigin(origin, url.origin)) {
return json({ message: 'Cross-origin mechanic gap reports are not allowed.' }, { status: 403 });
}
const contentLength = Number(request.headers.get('content-length') ?? 0);
+12 -7
View File
@@ -413,7 +413,8 @@
summary: selectedFocus
? `${selectedFocus.primary_attribute} · ${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS`
: 'ROOT CHOICE · DEFINES ALL THREE ABILITIES',
iconUrl: selectedFocus?.specialization.iconUrl
iconUrl: selectedFocus?.specialization.iconUrl,
issue: !selectedFocus ? 'REQUIRED FOR CALCULATION' : selectedFocus.abilities.length !== 3 ? 'ABILITY DATA INCOMPLETE' : undefined
});
const abilityCells = $derived.by((): MatrixCell[] => [0, 1, 2].map((index) => {
const ability = selectedFocus?.abilities[index];
@@ -425,7 +426,8 @@
name: ability?.name ?? 'NOT ASSIGNED',
meta: ability?.attack?.damage_type ?? '',
summary: chipNames.length ? chipNames.join(' · ') : `${configuredChips.length} / 3 SKILL CHIPS`,
iconUrl: ability?.iconUrl
iconUrl: ability?.iconUrl,
issue: !ability ? 'ABILITY NOT CONFIGURED' : configuredChips.length < 3 ? `${3 - configuredChips.length} CHIP${configuredChips.length === 2 ? '' : 'S'} REQUIRED` : undefined
};
}));
const weaponCells = $derived.by((): MatrixCell[] => draft.weapons.map((selection, index) => {
@@ -438,7 +440,8 @@
name: item?.name ?? 'EMPTY SLOT',
meta: item?.category ?? '',
summary: item ? details || `T${selection.tier} · ${unitValue(selection.damage, '')} DAMAGE` : 'SELECT WEAPON',
iconUrl: weaponIcon(item?.category)
iconUrl: weaponIcon(item?.category),
issue: item ? undefined : 'REQUIRED FOR CALCULATION'
};
}));
const gearCells = $derived.by((): MatrixCell[] => gearSlots.map((slot) => {
@@ -455,7 +458,8 @@
name: item?.name ?? 'EMPTY SLOT',
meta: item ? `T${item.tier}` : '',
summary: item ? details || `T${item.tier} · LEVEL ${selection.level}` : 'SELECT GEAR',
iconUrl: gearIcon(slot)
iconUrl: gearIcon(slot),
issue: item ? undefined : 'REQUIRED FOR CALCULATION'
};
}));
const protocolCell = $derived<MatrixCell>({
@@ -466,7 +470,8 @@
summary: protocolItem()
? `LEVEL ${draft.protocol.level} · ${draft.protocol.talentUpgrades} / 5 TALENT UPGRADES`
: 'SELECT ONE PROTOCOL',
iconUrl: protocolItem()?.iconUrl
iconUrl: protocolItem()?.iconUrl,
issue: protocolItem() ? undefined : 'REQUIRED FOR CALCULATION'
});
const loadoutConfigured = $derived(Boolean(
selectedFocus
@@ -764,8 +769,8 @@
<aside class="loadout-summary">
<div class="summary-head"><div><p class="eyebrow"><span></span> LIVE SUMMARY</p><h2>AGENT STATS</h2></div><b>{summary.stats.length}</b></div>
<div class="summary-core">{#each ['Firepower','Toughness','Engineering'] as name}{@const stat = summary.stats.find((entry) => attribute(entry.id)?.name === name)}<div><GameIcon name={name.toLowerCase() as GameIconName} decorative/><span>{name}<b>{stat ? unitValue(stat.value,stat.id) : '0'}</b></span></div>{/each}</div>
<div class="builder-combat-results">{#each combatResults as result}<WeaponCalculationResult {result}/>{/each}</div>
<div class="builder-combat-results"><SkillCalculationResult result={skillCombatResult} abilityNames={skillAbilityNames}/></div>
<div class="builder-combat-results">{#each combatResults as result}<WeaponCalculationResult {result} resolverHint="Use the warning markers in the loadout matrix."/>{/each}</div>
<div class="builder-combat-results"><SkillCalculationResult result={skillCombatResult} abilityNames={skillAbilityNames} resolverHint="Open the marked doctrine or ability cell."/></div>
<div class="summary-scroll">
<div class="summary-section"><header><span>COMBINED ATTRIBUTES</span><b>{summary.stats.length}</b></header>{#each summary.stats as stat}<div class="summary-stat"><span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span><b>{unitValue(stat.value,stat.id)}</b></div>{:else}<p>Equip and configure items to calculate attributes.</p>{/each}</div>
<div class="summary-section"><header><span>ACTIVE SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>{#each summary.activeBonuses as bonus}<div class="summary-bonus"><span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span><em>+{unitValue(bonus.value,bonus.statId)} {attribute(bonus.statId)?.name}</em></div>{:else}<p>Matching Brand Sets and Skill Chips activate bonuses here.</p>{/each}</div>
@@ -1,7 +1,10 @@
<script lang="ts">
import PseudoScript from '$lib/components/PseudoScript.svelte';
import { TRIGGER_ACTION_REGISTRY } from '$lib/calculations/conditional';
import { MECHANIC_EVENT_REGISTRY } from '$lib/calculations/mechanic';
import {
MECHANIC_COMBAT_REFERENCE_REGISTRY,
MECHANIC_EVENT_REGISTRY
} from '$lib/calculations/mechanic';
type AttributeVariable = {
id: string;
@@ -57,13 +60,14 @@
{ token: 'COOLDOWN', meaning: 'Blocks that branch from activating again for the configured duration.' },
{ token: 'WEIGHT', meaning: 'Applies a scenario-dependent contribution to a branch.' },
{ token: 'ADD / SET / CONSUME / CLEAR / TRANSFER', meaning: 'Changes a named state pool.' },
{ token: 'APPLY', meaning: 'Emits an Attribute modifier, damage, restoration, resource cost, or marker.' }
{ token: 'APPLY / RELOAD', meaning: 'Emits an Attribute modifier, damage, restoration, resource cost, weapon action, or marker.' }
];
const valueSources = [
{ syntax: 'EVENT.<path>', use: 'A value carried by the current event, such as target distance or critical state.' },
{ syntax: 'SCENARIO.<key>', use: 'A builder assumption supplied by Maximum Potential or Custom Scenario mode.' },
{ syntax: 'COMBAT.<path>', use: 'The current calculated combat state, including current or maximum Health.' },
{ syntax: 'COMBAT.currentHealth', use: MECHANIC_COMBAT_REFERENCE_REGISTRY.currentHealth.description },
{ syntax: 'COMBAT.currentHealthPercent', use: `${MECHANIC_COMBAT_REFERENCE_REGISTRY.currentHealthPercent.description} “Below” conditions use a strict < comparison.` },
{ syntax: 'PRE-EVENT COMBAT.<path>', use: 'Combat state captured before the current branch changes it.' },
{ syntax: 'ATTRIBUTE[<id>]', use: 'The authoritative loadout total for a stable Attribute ID. Percentage Attributes use their displayed percentage value.' },
{ syntax: 'EQUIPMENT.<path>', use: 'A value from the equipped item, including OS Protocol talent upgrades.' },
@@ -108,6 +112,7 @@
{ token: 'DIRECT DAMAGE', detail: 'Deals damage from a flat value or a configured percentage basis.' },
{ token: 'RESTORATION', detail: 'Restores Health from a flat value or Health basis.' },
{ token: 'RESOURCE COST', detail: 'Consumes Health or another modeled resource in branch order.' },
{ token: 'WEAPON RELOAD', detail: 'Reloads the inactive weapon after the configured delay. A new activation refreshes a pending reload timer.' },
{ token: 'MARKER', detail: 'Emits a typed state marker without changing a numeric Attribute.' }
];
+117 -36
View File
@@ -20,7 +20,7 @@ import {
numberedTalentOutcomes
} from '$lib/calculations/talent-description';
type SharedEffect = { slot: number; statId?: string; valueI: number; valueII: number };
type SharedEffect = { slot: number; statId?: string; valueI: number; valueII?: number };
type TalentErrorArea = 'icon' | 'identity' | 'description' | 'mechanic' | 'outcomes';
type TalentValidationError = { area: TalentErrorArea; message: string };
@@ -70,7 +70,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
FROM talents`)).get(),
getPendingEntities(user.id, [
{ module: 'attributes', action: 'save', kind: 'stat', fields: ['unit'] },
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category', 'descriptionTemplate', 'conditionalRule'] }
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category', 'descriptionTemplate', 'conditionalRule', 'tierIIDocumented'] }
])
]);
const pendingStats = pending.filter((item) => item.kind === 'stat');
@@ -79,7 +79,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
: filter === 'slot2' ? item.category === 'Weapon Talent 2'
: filter === 'backpack' ? item.category === 'Backpack'
: filter === 'body-armor' ? item.category === 'Body Armor'
: filter === 'exotic' ? false : true
: filter === 'exotic' ? item.category === 'Exotic Weapon' : true
).filter((item) =>
!search || String(item.name).toLocaleLowerCase().includes(search.toLocaleLowerCase())
).map((item) => ({
@@ -92,9 +92,16 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
conditionalRule: item.conditionalRule ?? null,
ownerWeaponId: null,
ownerWeaponName: null,
tiers: ['I', 'II'].map((tier) => ({ tier, description: String(item.descriptionTemplate || 'PENDING TALENT CONFIGURATION') }))
tierIIDocumented: item.category !== 'Exotic Weapon' || String(item.tierIIDocumented ?? '') === '1',
tiers: (item.category === 'Exotic Weapon' && String(item.tierIIDocumented ?? '') !== '1' ? ['I'] : ['I', 'II'])
.map((tier) => ({ tier, description: String(item.descriptionTemplate || 'PENDING TALENT CONFIGURATION') }))
}));
const configuredTalents = await Promise.all(talents.map(async (talent) => ({
const configuredTalents = await Promise.all(talents.map(async (talent) => {
const talentTiers = tiers.filter((tier) => tier.talent_id === talent.id);
const weaponScope = weaponScopes.find((scope) => scope.talent_id === talent.id)?.scope;
const isExoticTalent = talent.category === 'Weapon' && weaponScope === 'Exotic';
const tierIIDocumented = !isExoticTalent || talentTiers.some((tier) => tier.tier === 'II');
return {
...talent,
iconUrl: (() => { const icon = icons.find((item) => item.entity_id === talent.id); return icon ? gameDataIconUrl('talent', String(talent.id), icon.updated_at) : ''; })(),
category: talent.category === 'Vest'
@@ -109,14 +116,21 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
? 'Weapon · Slot Unassigned'
: talent.category,
descriptionTemplate: templates.find((item) => item.talent_id === talent.id)?.template ?? '',
conditionalRule: await loadConditionalRuleDraft(db, 'talent', String(talent.id), ['I', 'II']),
conditionalRule: await loadConditionalRuleDraft(
db,
'talent',
String(talent.id),
tierIIDocumented ? ['I', 'II'] : ['I']
),
ownerWeaponId: exoticOwners.find((weapon) => weapon.exotic_talent_id === talent.id)?.id ?? null,
ownerWeaponName: exoticOwners.find((weapon) => weapon.exotic_talent_id === talent.id)?.name ?? null,
tiers: tiers.filter((tier) => tier.talent_id === talent.id).map((tier) => ({
tierIIDocumented,
tiers: talentTiers.map((tier) => ({
...tier,
effects: effects.filter((effect) => effect.talent_tier_id === tier.id)
}))
})));
};
}));
return {
talents: [...pendingTalents, ...configuredTalents],
stats: [...pendingStats, ...stats],
@@ -161,7 +175,7 @@ const renderDescription = (
const effect = effects.find((item) => item.slot === Number(expression));
return effect
? displayValue(
tier === 'I' ? effect.valueI : effect.valueII,
tier === 'I' ? effect.valueI : Number(effect.valueII),
effect.statId ? units.get(effect.statId) : undefined
)
: placeholder;
@@ -186,16 +200,20 @@ export const actions: Actions = {
throw error;
}
const name = String(form.get('name') ?? '').trim(), category = String(form.get('category') ?? '');
const isExotic = category === 'Exotic Weapon';
const tierIIDocumented = !isExotic || String(form.get('tierIIDocumented') ?? '') === '1';
const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').trim();
const db = await getDatabase();
const conditionalRuleRaw = form.get('conditionalRule');
let conditionalRule;
try {
const activeStats = await (await db.prepare('SELECT id FROM stat_attributes WHERE active=1')).all();
conditionalRule = parseConditionalRulePayload(conditionalRuleRaw, [
{ key: 'tierI', variantKey: 'I' },
{ key: 'tierII', variantKey: 'II' }
], {
conditionalRule = parseConditionalRulePayload(conditionalRuleRaw, tierIIDocumented
? [
{ key: 'tierI', variantKey: 'I' },
{ key: 'tierII', variantKey: 'II' }
]
: [{ key: 'tierI', variantKey: 'I' }], {
allowedAttributeIds: new Set(activeStats.map((row) => String(row.id))),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY))
});
@@ -206,32 +224,57 @@ export const actions: Actions = {
}]);
}
const cooldownSeconds = conditionalRule.clauses.find((clause) => clause.cooldownSeconds)?.cooldownSeconds ?? null;
const durationSeconds = conditionalRule.pools.find((pool) => pool.expiresAfterSeconds)?.expiresAfterSeconds ?? null;
const maxStacks = conditionalRule.pools.length
? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1))
const durationForTier = (tier: 'I' | 'II') => {
const pool = conditionalRule.pools.find((item) =>
item.expiresAfterSeconds !== undefined
|| item.expiresAfterSecondsVariants?.[tier] !== undefined);
return pool?.expiresAfterSecondsVariants?.[tier] ?? pool?.expiresAfterSeconds ?? null;
};
const durationSeconds = durationForTier('I');
const stackPools = conditionalRule.pools.filter((pool) => pool.stateKind === 'stack');
const maxStacks = stackPools.length
? Math.max(...stackPools.map((pool) => pool.max ?? 1))
: null;
const descriptionOutcomes = numberedTalentOutcomes(conditionalRule);
const sharedEffects: SharedEffect[] = descriptionOutcomes
.map((effect) => ({
slot: Number(effect.descriptionSlot),
...(effect.kind === 'attribute_modifier' && effect.attributeId ? { statId: effect.attributeId } : {}),
valueI: effect.variants.I.value,
valueII: effect.variants.II.value
}));
slot: Number(effect.descriptionSlot),
...(effect.attributeId ? { statId: effect.attributeId } : {}),
valueI: effect.variants.I.value,
...(tierIIDocumented ? { valueII: effect.variants.II.value } : {})
}));
const validationErrors: TalentValidationError[] = [];
const requiresCompleteMechanic = conditionalRule.simulationReadiness !== 'captured_only';
if (name.length < 2 || !['Weapon Talent 1', 'Weapon Talent 2', 'Backpack', 'Body Armor'].includes(category)) {
validationErrors.push({ area: 'identity', message: 'Provide a valid talent name and category. Exotic talents are managed from their owning weapon.' });
const requiresCompleteMechanic = isExotic || conditionalRule.simulationReadiness !== 'captured_only';
if (name.length < 2 || !['Weapon Talent 1', 'Weapon Talent 2', 'Exotic Weapon', 'Backpack', 'Body Armor'].includes(category)) {
validationErrors.push({ area: 'identity', message: 'Provide a valid talent name and category.' });
}
if (!descriptionTemplate) validationErrors.push({ area: 'description', message: 'Enter a shared description template.' });
if (requiresCompleteMechanic && !conditionalRule.clauses.length) {
validationErrors.push({ area: 'mechanic', message: 'Add at least one mechanic branch or mark the talent CAPTURE ONLY.' });
validationErrors.push({
area: 'mechanic',
message: isExotic
? 'Exotic talents require at least one modeled Tier I mechanic branch.'
: 'Add at least one mechanic branch or mark the talent CAPTURE ONLY.'
});
}
if (requiresCompleteMechanic && !sharedEffects.length) {
validationErrors.push({ area: 'outcomes', message: 'Add at least one numbered value outcome or mark the talent CAPTURE ONLY.' });
validationErrors.push({
area: 'outcomes',
message: isExotic
? 'Exotic talents require at least one numbered Tier I value outcome.'
: 'Add at least one numbered value outcome or mark the talent CAPTURE ONLY.'
});
}
if (sharedEffects.some((effect) => !Number.isFinite(effect.valueI) || !Number.isFinite(effect.valueII))) {
validationErrors.push({ area: 'outcomes', message: 'Every numbered outcome needs valid Tier I and Tier II values.' });
if (sharedEffects.some((effect) =>
!Number.isFinite(effect.valueI)
|| (tierIIDocumented && !Number.isFinite(effect.valueII))
)) {
validationErrors.push({
area: 'outcomes',
message: tierIIDocumented
? 'Every numbered outcome needs valid Tier I and Tier II values.'
: 'Every numbered outcome needs a valid Tier I value.'
});
}
const missingSlots = missingTalentDescriptionSlots(descriptionTemplate, descriptionOutcomes);
if (requiresCompleteMechanic && missingSlots.length) {
@@ -241,7 +284,11 @@ export const actions: Actions = {
});
validationErrors.push({ area: 'outcomes', message: 'Add or renumber outcomes to match every numbered description placeholder.' });
}
if (requiresCompleteMechanic && descriptionTemplate.includes('{duration}') && durationSeconds === null) {
if (
requiresCompleteMechanic
&& descriptionTemplate.includes('{duration}')
&& (durationForTier('I') === null || (tierIIDocumented && durationForTier('II') === null))
) {
validationErrors.push({ area: 'description', message: 'The description uses {duration}, but no duration is defined.' });
validationErrors.push({ area: 'mechanic', message: 'Define a duration for the {duration} description placeholder.' });
}
@@ -249,8 +296,21 @@ export const actions: Actions = {
validationErrors.push({ area: 'description', message: 'The description uses {max_stacks}, but no maximum stack count is defined.' });
validationErrors.push({ area: 'mechanic', message: 'Define a pool maximum for the {max_stacks} description placeholder.' });
}
const ownerWeapon = id
? await (await db.prepare('SELECT id,name FROM weapons WHERE exotic_talent_id=?')).get(id)
: null;
if (ownerWeapon && !isExotic) {
validationErrors.push({
area: 'identity',
message: `This talent is owned by ${String(ownerWeapon.name)} and must remain an Exotic Weapon talent.`
});
}
if (validationErrors.length) return reject(400, validationErrors);
const storedCategory = category === 'Body Armor' ? 'Vest' : category.startsWith('Weapon Talent') ? 'Weapon' : category;
const storedCategory = category === 'Body Armor'
? 'Vest'
: category.startsWith('Weapon Talent') || isExotic
? 'Weapon'
: category;
if (await (await db.prepare('SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category=? AND id<>?')).get(name, storedCategory, id || 'new')) {
return reject(409, [{ area: 'identity', message: 'That talent already exists in this category.' }]);
}
@@ -270,6 +330,9 @@ export const actions: Actions = {
icon: await gameDataIconMetadata(db, 'talent', id),
template: await (await db.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(id),
tiers: await (await db.prepare('SELECT tier,description FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(id),
weaponScope: await (await db.prepare('SELECT scope FROM talent_weapon_scopes WHERE talent_id=?')).get(id),
weaponSlot: await (await db.prepare('SELECT slot FROM weapon_talent_slots WHERE talent_id=?')).get(id),
ownerWeapon,
conditional: await conditionalRuleSnapshot(db, 'talent', id)
} : null;
await db.exec('BEGIN');
@@ -279,25 +342,38 @@ export const actions: Actions = {
if (storedCategory === 'Weapon') {
await (await db.prepare(`INSERT INTO talent_weapon_scopes(talent_id,scope) VALUES(?,?)
ON CONFLICT(talent_id) DO UPDATE SET scope=excluded.scope`))
.run(talentId, 'Standard');
await (await db.prepare(`INSERT INTO weapon_talent_slots(talent_id,slot) VALUES(?,?)
ON CONFLICT(talent_id) DO UPDATE SET slot=excluded.slot`))
.run(talentId, category === 'Weapon Talent 1' ? 1 : 2);
.run(talentId, isExotic ? 'Exotic' : 'Standard');
if (isExotic) {
await (await db.prepare('DELETE FROM weapon_talent_slots WHERE talent_id=?')).run(talentId);
} else {
await (await db.prepare(`INSERT INTO weapon_talent_slots(talent_id,slot) VALUES(?,?)
ON CONFLICT(talent_id) DO UPDATE SET slot=excluded.slot`))
.run(talentId, category === 'Weapon Talent 1' ? 1 : 2);
}
} else {
await (await db.prepare('DELETE FROM talent_weapon_scopes WHERE talent_id=?')).run(talentId);
await (await db.prepare('DELETE FROM weapon_talent_slots WHERE talent_id=?')).run(talentId);
}
await (await db.prepare(`INSERT INTO talent_description_templates(talent_id,template,updated_at) VALUES(?,?,?)
ON CONFLICT(talent_id) DO UPDATE SET template=excluded.template,updated_at=excluded.updated_at`)).run(talentId, descriptionTemplate, now);
for (const tier of ['I', 'II'] as const) {
const description = renderDescription(descriptionTemplate, sharedEffects, units, tier, durationSeconds, maxStacks);
if (!tierIIDocumented) {
await (await db.prepare("DELETE FROM talent_tiers WHERE talent_id=? AND tier='II'")).run(talentId);
}
for (const tier of (tierIIDocumented ? ['I', 'II'] : ['I']) as Array<'I' | 'II'>) {
const description = renderDescription(descriptionTemplate, sharedEffects, units, tier, durationForTier(tier), maxStacks);
await (await db.prepare(`INSERT INTO talent_tiers(id,talent_id,tier,description,trigger_text,effect_text,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(talent_id,tier) DO UPDATE SET description=excluded.description,updated_at=excluded.updated_at`)).run(randomUUID(), talentId, tier, description, '', '', now, now);
const tierRow = await (await db.prepare('SELECT id FROM talent_tiers WHERE talent_id=? AND tier=?')).get(talentId, tier);
const tierId = String(tierRow?.id);
await (await db.prepare('DELETE FROM talent_tier_effects WHERE talent_tier_id=?')).run(tierId);
const storedLegacyAttributes = new Set<string>();
for (const outcome of descriptionOutcomes) {
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId) continue;
// Conditional mechanics can apply the same attribute in several
// mutually exclusive branches. The legacy projection has one row
// per attribute and is not the runtime source for modeled talents.
if (storedLegacyAttributes.has(outcome.attributeId)) continue;
storedLegacyAttributes.add(outcome.attributeId);
await (await db.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)')).run(
randomUUID(),
tierId,
@@ -319,6 +395,11 @@ export const actions: Actions = {
record: await auditRecord('talents', talentId),
icon: await gameDataIconMetadata(db, 'talent', talentId),
category,
tierIIDocumented,
weaponScope: await (await db.prepare('SELECT scope FROM talent_weapon_scopes WHERE talent_id=?')).get(talentId),
weaponSlot: await (await db.prepare('SELECT slot FROM weapon_talent_slots WHERE talent_id=?')).get(talentId),
ownerWeapon: await (await db.prepare('SELECT id,name FROM weapons WHERE exotic_talent_id=?')).get(talentId),
tiers: await (await db.prepare('SELECT tier,description FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(talentId),
descriptionTemplate,
conditional: await conditionalRuleSnapshot(db, 'talent', talentId)
}
+67 -9
View File
@@ -11,7 +11,11 @@
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import { tick } from 'svelte';
let { data, form } = $props();
const categoryOptions = ['Weapon Talent 1', 'Weapon Talent 2', 'Backpack', 'Body Armor'].map((value) => ({ value, label: value }));
const categoryOptions = ['Weapon Talent 1', 'Weapon Talent 2', 'Exotic Weapon', 'Backpack', 'Body Armor'].map((value) => ({ value, label: value }));
const tierIIDocumentationOptions = [
{ value: 'documented', label: 'DOCUMENTED' },
{ value: 'not_documented', label: 'NOT YET DOCUMENTED' }
];
let talent = $state<any>(null);
let handledResultToken = $state('');
let iconResetToken = $state(0);
@@ -44,7 +48,7 @@
pools: [],
clauses: []
});
const blankTalent = () => ({ id: '', name: '', iconUrl: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', conditionalRule: blankRule(), modelingOpen: false });
const blankTalent = () => ({ id: '', name: '', iconUrl: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', tierIIDocumented: true, tierIIDocumentation: 'documented', conditionalRule: blankRule(), modelingOpen: false });
const editTalent = (row: any) => {
const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', effects: [] };
const one = tier('I'), two = tier('II');
@@ -53,10 +57,51 @@
descriptionTemplate: row.descriptionTemplate || one.description,
tierIDescription: one.description,
tierIIDescription: two.description,
tierIIDocumented: row.category !== 'Exotic Weapon' || Boolean(row.tierIIDocumented),
tierIIDocumentation: row.category === 'Exotic Weapon' && !row.tierIIDocumented ? 'not_documented' : 'documented',
conditionalRule: row.conditionalRule ?? blankRule(),
modelingOpen: Boolean(row.conditionalRule?.clauses?.length)
};
};
const setTierIIDocumented = (documented: boolean) => {
if (!talent) return;
talent.tierIIDocumented = documented;
talent.tierIIDocumentation = documented ? 'documented' : 'not_documented';
talent.conditionalRule = {
...talent.conditionalRule,
pools: talent.conditionalRule.pools.map((pool: any) => {
if (!pool.expiresAfterSecondsVariants) return pool;
const tierI = pool.expiresAfterSecondsVariants.I;
return {
...pool,
expiresAfterSecondsVariants: documented
? { ...pool.expiresAfterSecondsVariants, I: tierI, II: pool.expiresAfterSecondsVariants.II ?? tierI }
: { I: tierI }
};
}),
clauses: talent.conditionalRule.clauses.map((clause: any) => ({
...clause,
outcomes: clause.outcomes.map((outcome: any) => ({
...outcome,
variants: documented
? {
...outcome.variants,
I: outcome.variants.I,
II: structuredClone(outcome.variants.II ?? outcome.variants.I ?? { value: 0 })
}
: { I: outcome.variants.I }
}))
}))
};
};
const setCategory = (category: string) => {
if (!talent) return;
talent.category = category;
if (category !== 'Exotic Weapon' && !talent.tierIIDocumented) setTierIIDocumented(true);
};
const valueColumns = () => talent?.category === 'Exotic Weapon' && !talent.tierIIDocumented
? [{ key: 'tierI', variantKey: 'I', label: 'TIER I' }]
: [{ key: 'tierI', variantKey: 'I', label: 'TIER I' }, { key: 'tierII', variantKey: 'II', label: 'TIER II' }];
const configured = (tiers: Array<{ tier: unknown }>, name: string) => tiers.some((item) => item.tier === name);
const tierDescription = (tiers: Array<{ tier: unknown; description?: unknown }>, name: string) => {
const description = tiers.find((item) => item.tier === name)?.description;
@@ -70,16 +115,22 @@
};
const mechanicValue = (value: string | number, singular: string) => `${value} ${Number(value) === 1 ? singular : `${singular}s`}`;
const cooldownLine = (value: unknown) => value === null || value === undefined || value === '' ? '' : `Talent Cooldown: ${mechanicValue(value as string | number, 'second')}`;
const preview = (tier: 'I' | 'II') => talent.descriptionTemplate.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => {
const duration = talent.conditionalRule.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds;
const maxStacks = talent.conditionalRule.pools.length ? Math.max(...talent.conditionalRule.pools.map((pool: any) => pool.max ?? 1)) : null;
const preview = (tier: 'I' | 'II') => {
if (tier === 'II' && talent.category === 'Exotic Weapon' && !talent.tierIIDocumented) return 'NOT YET DOCUMENTED';
return talent.descriptionTemplate.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => {
const durationPool = talent.conditionalRule.pools.find((pool: any) =>
pool.expiresAfterSeconds !== undefined || pool.expiresAfterSecondsVariants?.[tier] !== undefined);
const duration = durationPool?.expiresAfterSecondsVariants?.[tier] ?? durationPool?.expiresAfterSeconds;
const stackPools = talent.conditionalRule.pools.filter((pool: any) => pool.stateKind === 'stack');
const maxStacks = stackPools.length ? Math.max(...stackPools.map((pool: any) => pool.max ?? 1)) : null;
if (expression === 'duration') return duration == null ? placeholder : mechanicValue(duration, 'second');
if (expression === 'max_stacks') return maxStacks == null ? placeholder : mechanicValue(maxStacks, 'stack');
const effect = talent.conditionalRule.clauses
.flatMap((clause: any) => clause.outcomes)
.find((outcome: any) => outcome.descriptionSlot === Number(expression));
return effect ? displayValue(effect, tier) : placeholder;
});
});
};
$effect(() => {
if (!form?.resultToken || form.resultToken === handledResultToken) return;
handledResultToken = form.resultToken;
@@ -103,6 +154,7 @@
<div class="editor-title"><div><p class="eyebrow">{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}</p><h3>{talent.id ? talent.name : 'TALENT DEFINITION'}</h3></div><button aria-label="Close talent editor" onclick={() => talent = null}>×</button></div>
<PendingForm action="?/saveTalent" enctype="multipart/form-data">
<input type="hidden" name="id" value={talent.id}/><input type="hidden" name="conditionalRule" value={JSON.stringify(talent.conditionalRule)}/>
<input type="hidden" name="tierIIDocumented" value={talent.tierIIDocumented ? '1' : '0'}/>
{#if talentErrors.length}
<div class="validation-summary" role="alert" tabindex="-1" bind:this={validationSummary}>
<strong>CHECK {new Set(talentErrors.map((error) => error.area)).size} HIGHLIGHTED {new Set(talentErrors.map((error) => error.area)).size === 1 ? 'AREA' : 'AREAS'}</strong>
@@ -120,10 +172,16 @@
</div>
<div id="talent-identity" class="talent-identity" class:validation-area-error={hasErrors(['identity'])}>
<label class="field">NAME<input name="name" bind:value={talent.name} required aria-invalid={hasErrors(['identity']) ? 'true' : undefined}/></label>
<SegmentedChoice legend="CATEGORY / SLOT" name="category" options={categoryOptions} bind:value={talent.category} required/>
<SegmentedChoice legend="CATEGORY / SLOT" name="category" options={categoryOptions} bind:value={talent.category} onchange={(values) => setCategory(values[0])} required/>
{#each errorsFor('identity') as error}<small class="field-error">{error.message}</small>{/each}
</div>
</div>
{#if talent.category === 'Exotic Weapon'}
<div class="form-grid">
<SegmentedChoice legend="TIER II DOCUMENTATION" name="tierIIDocumentation" options={tierIIDocumentationOptions} bind:value={talent.tierIIDocumentation} onchange={(values) => setTierIIDocumented(values[0] === 'documented')} required/>
<p class="console-note">{talent.tierIIDocumented ? 'Tier I and Tier II values will be modeled and saved.' : 'Tier I is required. Tier II will remain absent until its game values are documented.'}</p>
</div>
{/if}
<label id="talent-description" class="field template-field" class:validation-area-error={hasErrors(['description'])}>SHARED DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={talent.descriptionTemplate} placeholder={'Hitting an enemy {1} times grants +{2} for {duration}...'} required aria-invalid={hasErrors(['description']) ? 'true' : undefined}></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values. Use {'{duration}'} and {'{max_stacks}'} for defined mechanics. Cooldown is displayed separately.</small>{#each errorsFor('description') as error}<small class="field-error">{error.message}</small>{/each}</label>
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{preview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{preview('II') || 'Description preview'}</p></div></div>
<details id="talent-mechanic" class="modeling-disclosure" class:validation-area-error={hasErrors(['mechanic', 'outcomes'])} bind:open={talent.modelingOpen}>
@@ -134,7 +192,7 @@
<div class="modeling-content">
<MechanicProgramEditor
attributes={data.stats}
valueColumns={[{key:'tierI',variantKey:'I',label:'TIER I'},{key:'tierII',variantKey:'II',label:'TIER II'}]}
valueColumns={valueColumns()}
idPrefix={`talent-${talent.id || 'new'}`}
aiContext={{
sourceKind: 'talent',
@@ -157,4 +215,4 @@
{/if}
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><SortableColumnHeader label="TALENT" column="name" sort={data.sort}/><SortableColumnHeader label="CATEGORY" column="category" sort={data.sort}/><SortableColumnHeader label="TIER I EFFECT" column="tier1" sort={data.sort}/><SortableColumnHeader label="TIER II EFFECT" column="tier2" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row" class:pending-record={item.pending} class:registry-row-editing={String(talent?.id ?? '') === String(item.id)}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small>{#if cooldownLine(item.cooldown_seconds)}<small class="talent-cooldown">{cooldownLine(item.cooldown_seconds)}</small>{/if}</div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{tierDescription(item.tiers, 'II')}</p><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : item.conditionalRule?.clauses?.length ? 'MODELED' : 'CONFIG REQUIRED'}</b>{#if item.pending}<span></span>{:else if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}<a class="row-action" href={`/isac/weapons?weapon=${item.ownerWeaponId}`}>EDIT WEAPON</a>{:else}<span></span>{/if}{:else}<button aria-expanded={String(talent?.id ?? '') === String(item.id)} aria-controls={`talent-editor-${item.id}`} onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{#if String(talent?.id ?? '') === String(item.id)}<RegistryInlineEditor id={`talent-editor-${item.id}`} label={`Edit ${item.name}`}>{@render talentEditor()}</RegistryInlineEditor>{/if}{/each}</div>
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><SortableColumnHeader label="TALENT" column="name" sort={data.sort}/><SortableColumnHeader label="CATEGORY" column="category" sort={data.sort}/><SortableColumnHeader label="TIER I EFFECT" column="tier1" sort={data.sort}/><SortableColumnHeader label="TIER II EFFECT" column="tier2" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row" class:pending-record={item.pending} class:registry-row-editing={String(talent?.id ?? '') === String(item.id)}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small>{#if cooldownLine(item.cooldown_seconds)}<small class="talent-cooldown">{cooldownLine(item.cooldown_seconds)}</small>{/if}</div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{item.category === 'Exotic Weapon' && !item.tierIIDocumented ? 'NOT YET DOCUMENTED' : tierDescription(item.tiers, 'II')}</p><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : item.conditionalRule?.clauses?.length ? 'MODELED' : 'CONFIG REQUIRED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={String(talent?.id ?? '') === String(item.id)} aria-controls={`talent-editor-${item.id}`} onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{#if String(talent?.id ?? '') === String(item.id)}<RegistryInlineEditor id={`talent-editor-${item.id}`} label={`Edit ${item.name}`}>{@render talentEditor()}</RegistryInlineEditor>{/if}{/each}</div>
+50 -161
View File
@@ -6,15 +6,8 @@ import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination';
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
import { gameDataIconUrl } from '$lib/server/game-data-icons';
import { getPendingEntities } from '$lib/server/data-moderation';
import {
conditionalRuleSnapshot,
loadConditionalRuleDraft,
parseConditionalRulePayload,
saveConditionalRuleInTransaction
} from '$lib/server/conditional-effects';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
import { WEAPON_CATEGORIES } from '$lib/weapons/category-bonuses';
import { normalizeWeaponModSlotPayload, validateWeaponModSlots, WEAPON_MOD_SLOT_TYPES } from '$lib/weapons/mod-slots';
@@ -22,26 +15,6 @@ const categories: readonly string[] = WEAPON_CATEGORIES;
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
const rangeNames = ['damage'] as const;
const modSlotTypes = WEAPON_MOD_SLOT_TYPES;
type ExoticEffect = { statId: string | null; valueI: number; valueII: number };
const mechanicValue = (value: number, singular: string) => `${value} ${value === 1 ? singular : `${singular}s`}`;
const renderTalentDescription = (template: string, effects: ExoticEffect[], units: Map<string, unknown>, tier: 'I' | 'II', duration: number | null, maxStacks: number | null) =>
template.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder, expression) => {
if (expression === 'duration') return duration === null ? placeholder : mechanicValue(duration, 'second');
if (expression === 'max_stacks') return maxStacks === null ? placeholder : mechanicValue(maxStacks, 'stack');
const effect = effects[Number(expression) - 1];
if (!effect) return placeholder;
const value = tier === 'I' ? effect.valueI : effect.valueII;
const unitValue = effect.statId ? units.get(effect.statId) : '';
const unit = unitValue === 'flat' ? '' : String(unitValue ?? '');
return unit === 'Shots' ? `${value} ${unit}` : `${value}${unit}`;
});
const mechanicOutcomes = (rule: ReturnType<typeof parseConditionalRulePayload>) =>
rule.clauses.flatMap((clause) => clause.outcomes);
const descriptionOutcomes = (rule: ReturnType<typeof parseConditionalRulePayload>) =>
mechanicOutcomes(rule).filter((outcome) => outcome.descriptionSlot)
.sort((left, right) => Number(left.descriptionSlot) - Number(right.descriptionSlot));
async function bonusProfileSnapshot(category: string) {
const database = await getDatabase();
@@ -87,7 +60,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const whereArgs = [...(requestedWeaponId ? [requestedWeaponId] : []), ...(!requestedWeaponId && search ? [pattern] : [])];
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM weapons ${where}`)).get(...whereArgs);
const pagination = getPagination(url, Number(filtered?.total ?? 0));
const [weapons, tierRanges, modSlots, talentOptions, augmentEffects, categoryBonuses, talent1Options, talent2Options, stats, exoticTemplates, talentIcons, pending] = await Promise.all([
const [weapons, tierRanges, modSlots, talentOptions, augmentEffects, categoryBonuses, talent1Options, talent2Options, exoticTalentOptions, stats, talentIcons, pending] = await Promise.all([
(await database.prepare(`SELECT * FROM weapons ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...whereArgs, pagination.perPage, pagination.offset),
(await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(),
(await database.prepare('SELECT * FROM weapon_mod_slots ORDER BY tier,slot_type')).all(),
@@ -107,15 +80,19 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
JOIN weapon_talent_slots ON weapon_talent_slots.talent_id=talents.id
WHERE talents.active=1 AND talents.category='Weapon' AND talent_weapon_scopes.scope='Standard' AND weapon_talent_slots.slot=2
ORDER BY talents.name COLLATE NOCASE`)).all(),
(await database.prepare(`SELECT talents.id,talents.name,weapons.id AS owner_weapon_id,weapons.name AS owner_weapon_name,
EXISTS(SELECT 1 FROM talent_tiers WHERE talent_tiers.talent_id=talents.id AND talent_tiers.tier='II') AS tier_ii_documented
FROM talents
JOIN talent_weapon_scopes ON talent_weapon_scopes.talent_id=talents.id
LEFT JOIN weapons ON weapons.exotic_talent_id=talents.id
WHERE talents.active=1 AND talents.category='Weapon' AND talent_weapon_scopes.scope='Exotic'
ORDER BY talents.name COLLATE NOCASE`)).all(),
(await database.prepare("SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE")).all(),
(await database.prepare(`SELECT talent_description_templates.talent_id,talent_description_templates.template,talents.name,
talents.cooldown_seconds,talents.duration_seconds,talents.max_stacks
FROM talent_description_templates JOIN talents ON talents.id=talent_description_templates.talent_id`)).all(),
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all(),
getPendingEntities(user.id, [
{ module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] },
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] },
{ module: 'weapons', action: 'save', kind: 'weapon', fields: ['category', 'nativeTier', 'damageType', 'isExotic', 'rpm', 'magazine', 'reloadSpeed', 'accuracy', 'stability', 'talent1Ids', 'talent2Ids', 'modSlots', 'exoticTalentName', 'exoticDescriptionTemplate', 'exoticConditionalRule'] }
{ module: 'weapons', action: 'save', kind: 'weapon', fields: ['category', 'nativeTier', 'damageType', 'isExotic', 'rpm', 'magazine', 'reloadSpeed', 'accuracy', 'stability', 'talent1Ids', 'talent2Ids', 'modSlots', 'exoticTalentId'] }
])
]);
const pendingTalents = pending.filter((item) => item.kind === 'talent');
@@ -141,13 +118,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
native_tier: Number(weapon.nativeTier),
damage_type: weapon.damageType,
is_exotic: weapon.isExotic ? 1 : 0,
exotic_talent_id: String(weapon.exoticTalentId ?? ''),
reload_speed: weapon.reloadSpeed,
tiers: [],
modSlots: (() => { try { return JSON.parse(String(weapon.modSlots ?? '[]')); } catch { return []; } })(),
talent1Ids: Array.isArray(weapon.talent1Ids) ? weapon.talent1Ids : [weapon.talent1Ids].filter(Boolean),
talent2Ids: Array.isArray(weapon.talent2Ids) ? weapon.talent2Ids : [weapon.talent2Ids].filter(Boolean),
augmentEffects: [],
exoticTalent: null
augmentEffects: []
})).sort((left, right) => {
const valueFor = (weapon: any) => sort.key === 'class' ? `${weapon.category}:${weapon.native_tier}`
: sort.key === 'fixed' ? Number(weapon.rpm ?? 0)
@@ -162,34 +139,32 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
: String(leftValue).localeCompare(String(rightValue), undefined, { sensitivity: 'base', numeric: true });
return (sort.direction === 'asc' ? result : -result) || String(left.name).localeCompare(String(right.name), undefined, { sensitivity: 'base' });
});
const configuredWeapons = await Promise.all(weapons.map(async (weapon) => {
const talentId = typeof weapon.exotic_talent_id === 'string' ? weapon.exotic_talent_id : '';
const conditionalRule = talentId
? await loadConditionalRuleDraft(database, 'talent', talentId, ['I', 'II'])
: null;
return {
const configuredWeapons = weapons.map((weapon) => ({
...weapon,
tiers: tierRanges.filter((range) => range.weapon_id === weapon.id),
modSlots: [0,1,2].map((tier) => ({ tier, slots: modSlots.filter((slot) => slot.weapon_id === weapon.id && Number(slot.tier) === tier).map((slot) => String(slot.slot_type)) })),
talent1Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 1).map((option) => String(option.talent_id)),
talent2Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 2).map((option) => String(option.talent_id)),
augmentEffects: augmentEffects.filter((effect) => effect.weapon_id === weapon.id),
baseBonuses: categoryBonuses.filter((effect) => effect.weapon_category === weapon.category),
exoticTalent: talentId ? {
id: talentId,
iconUrl: (() => { const icon = talentIcons.find((item) => item.entity_id === talentId); return icon ? gameDataIconUrl('talent', talentId, icon.updated_at) : ''; })(),
name: String(exoticTemplates.find((item) => item.talent_id === talentId)?.name ?? ''),
cooldown_seconds: exoticTemplates.find((item) => item.talent_id === talentId)?.cooldown_seconds ?? '',
duration_seconds: exoticTemplates.find((item) => item.talent_id === talentId)?.duration_seconds ?? '',
max_stacks: exoticTemplates.find((item) => item.talent_id === talentId)?.max_stacks ?? '',
descriptionTemplate: String(exoticTemplates.find((item) => item.talent_id === talentId)?.template ?? ''),
conditionalRule
} : null
}}));
baseBonuses: categoryBonuses.filter((effect) => effect.weapon_category === weapon.category)
}));
return {
weapons: [...pendingWeapons, ...configuredWeapons],
talent1Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 1'), ...talent1Options].map(talentOptionWithIcon),
talent2Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 2'), ...talent2Options].map(talentOptionWithIcon),
exoticTalentOptions: exoticTalentOptions.map((talent) => {
const talentId = String(talent.id);
const icon = talentIcons.find((item) => String(item.entity_id) === talentId);
return {
id: talentId,
name: String(talent.name),
ownerWeaponId: talent.owner_weapon_id ? String(talent.owner_weapon_id) : '',
ownerWeaponName: talent.owner_weapon_name ? String(talent.owner_weapon_name) : '',
tierIIDocumented: Boolean(talent.tier_ii_documented),
showIcon: true,
iconUrl: icon ? gameDataIconUrl('talent', talentId, icon.updated_at) : ''
};
}),
stats: [...pending.filter((item) => item.kind === 'attribute'), ...stats],
summary: { ...summary, total: Number(summary?.total ?? 0) + pending.filter((item) => item.kind === 'weapon').length, pending: pending.filter((item) => item.kind === 'weapon').length },
pagination,
@@ -257,9 +232,6 @@ export const actions: Actions = {
save: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
let exoticIconMutation;
try { exoticIconMutation = await readIconMutation(form); }
catch (error) { if (error instanceof GameDataIconValidationError) return fail(400, { weaponError: error.message }); throw error; }
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
const category = String(form.get('category') ?? '');
@@ -280,38 +252,8 @@ export const actions: Actions = {
return normalizeWeaponModSlotPayload(JSON.parse(String(form.get('modSlots') ?? '[]')));
} catch { return []; }
})();
const existingExoticTalentId = isExotic ? String(form.get('exoticTalentId') ?? '') : '';
const exoticTalentName = isExotic ? String(form.get('exoticTalentName') ?? '').normalize('NFKC').trim() : '';
const exoticDescriptionTemplate = isExotic ? String(form.get('exoticDescriptionTemplate') ?? '').trim() : '';
const exoticTalentId = isExotic ? String(form.get('exoticTalentId') ?? '') : '';
const database = await getDatabase();
let exoticConditionalRule = null;
if (isExotic) {
const rawRule = form.get('exoticConditionalRule');
try {
const activeStats = await (await database.prepare('SELECT id FROM stat_attributes WHERE active=1')).all();
exoticConditionalRule = parseConditionalRulePayload(rawRule, [
{ key: 'tierI', variantKey: 'I' },
{ key: 'tierII', variantKey: 'II' }
], {
allowedAttributeIds: new Set(activeStats.map((row) => String(row.id))),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY))
});
} catch (error) {
return fail(400, { weaponError: error instanceof Error ? error.message : 'The exotic talent mechanic is invalid.' });
}
}
const exoticCooldownSeconds = exoticConditionalRule?.clauses.find((clause) => clause.cooldownSeconds)?.cooldownSeconds ?? null;
const exoticDurationSeconds = exoticConditionalRule?.pools.find((pool) => pool.expiresAfterSeconds)?.expiresAfterSeconds ?? null;
const exoticMaxStacks = exoticConditionalRule?.pools.length
? Math.max(...exoticConditionalRule.pools.map((pool) => pool.max ?? 1))
: null;
const exoticEffects: ExoticEffect[] = exoticConditionalRule ? descriptionOutcomes(exoticConditionalRule)
.filter((outcome) => outcome.kind === 'attribute_modifier')
.map((outcome) => ({
statId: outcome.attributeId ?? null,
valueI: outcome.variants.I.value,
valueII: outcome.variants.II.value
})) : [];
if (name.length < 2 || name.length > 80) return fail(400, { weaponError: 'Weapon names must be 2–80 characters.' });
if (!categories.includes(category) || !damageTypes.includes(damageType) || !Number.isInteger(nativeTier) || nativeTier < 0 || nativeTier > 2) {
@@ -329,15 +271,7 @@ export const actions: Actions = {
}
const modSlotError = validateWeaponModSlots(nativeTier, modSlots);
if (modSlotError) return fail(400, { weaponError: modSlotError });
if (isExotic && (exoticTalentName.length < 2 || !exoticDescriptionTemplate || !exoticConditionalRule)) {
return fail(400, { weaponError: 'Exotic weapons require a named talent, description template, and modeled conditional mechanic.' });
}
if (isExotic) {
const placeholders = [...exoticDescriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1]));
if (placeholders.some((index) => index < 1 || index > exoticEffects.length)) return fail(400, { weaponError: 'The exotic talent description references a missing effect.' });
if (exoticDescriptionTemplate.includes('{duration}') && exoticDurationSeconds === null) return fail(400, { weaponError: 'The exotic talent description uses {duration}, but no duration is defined.' });
if (exoticDescriptionTemplate.includes('{max_stacks}') && exoticMaxStacks === null) return fail(400, { weaponError: 'The exotic talent description uses {max_stacks}, but no maximum stack count is defined.' });
}
if (isExotic && !exoticTalentId) return fail(400, { weaponError: 'Select a registered exotic talent for this weapon.' });
const tiers = [];
try {
@@ -374,22 +308,23 @@ export const actions: Actions = {
AND weapon_talent_slots.slot=? AND talents.id IN (${ids.map(() => '?').join(',')})`)).get(slot, ...ids);
if (Number(validTalents?.count) !== ids.length) return fail(400, { weaponError: `One or more selected Talent ${slot} options are unavailable or assigned to the wrong slot.` });
}
const statIds = [...new Set([
...augmentEffects.map((effect) => effect.statId),
...exoticEffects.map((effect) => effect.statId).filter((value): value is string => Boolean(value))
])];
const statIds = [...new Set(augmentEffects.map((effect) => effect.statId))];
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).get(...statIds);
if (Number(validStats?.count) !== statIds.length) return fail(400, { weaponError: 'One or more tactical augmentation attributes are unavailable.' });
if (isExotic) {
if (existingExoticTalentId) {
const owned = id ? await (await database.prepare('SELECT id FROM weapons WHERE id=? AND exotic_talent_id=?')).get(id, existingExoticTalentId) : null;
if (!owned) return fail(400, { weaponError: 'The submitted exotic talent is not owned by this weapon.' });
const shared = await (await database.prepare('SELECT id FROM weapons WHERE exotic_talent_id=? AND id<>?')).get(existingExoticTalentId, id || 'new');
if (shared) return fail(409, { weaponError: 'That exotic talent is already owned by another weapon and cannot be shared.' });
}
if (await (await database.prepare("SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category='Weapon' AND id<>?")).get(exoticTalentName, existingExoticTalentId || 'new')) {
return fail(409, { weaponError: 'An exotic talent with that name already exists.' });
}
const selectedExoticTalent = isExotic
? await (await database.prepare(`SELECT talents.id,talents.name,weapons.id AS owner_weapon_id,
EXISTS(SELECT 1 FROM talent_tiers WHERE talent_tiers.talent_id=talents.id AND talent_tiers.tier='II') AS tier_ii_documented
FROM talents
JOIN talent_weapon_scopes ON talent_weapon_scopes.talent_id=talents.id
LEFT JOIN weapons ON weapons.exotic_talent_id=talents.id
WHERE talents.id=? AND talents.active=1 AND talents.category='Weapon'
AND talent_weapon_scopes.scope='Exotic'`)).get(exoticTalentId)
: null;
if (isExotic && !selectedExoticTalent) {
return fail(400, { weaponError: 'The selected exotic talent is unavailable or is not classified for exotic weapons.' });
}
if (selectedExoticTalent?.owner_weapon_id && String(selectedExoticTalent.owner_weapon_id) !== id) {
return fail(409, { weaponError: 'That exotic talent is already assigned to another weapon.' });
}
if (id && !await (await database.prepare('SELECT id FROM weapons WHERE id=?')).get(id)) {
return fail(404, { weaponError: 'Weapon not found.' });
@@ -405,71 +340,25 @@ export const actions: Actions = {
const weaponId = id || randomUUID();
const now = new Date().toISOString();
const exoticTalentId = isExotic ? existingExoticTalentId || randomUUID() : null;
const before = id ? {
record: await auditRecord('weapons', id),
tiers: await (await database.prepare('SELECT * FROM weapon_tier_ranges WHERE weapon_id=? ORDER BY tier')).all(id),
talents: await (await database.prepare('SELECT * FROM weapon_talent_options WHERE weapon_id=? ORDER BY talent_slot')).all(id),
augmentEffects: await (await database.prepare('SELECT * FROM weapon_augmentation_effects WHERE weapon_id=? ORDER BY sort_order')).all(id),
modSlots: await (await database.prepare('SELECT * FROM weapon_mod_slots WHERE weapon_id=? ORDER BY tier,slot_type')).all(id),
exoticTalent: existingExoticTalentId ? {
record: await (await database.prepare('SELECT * FROM talents WHERE id=?')).get(existingExoticTalentId),
icon: await gameDataIconMetadata(database, 'talent', existingExoticTalentId),
template: await (await database.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(existingExoticTalentId),
tiers: await (await database.prepare('SELECT * FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(existingExoticTalentId),
conditional: await conditionalRuleSnapshot(database, 'talent', existingExoticTalentId)
} : null
modSlots: await (await database.prepare('SELECT * FROM weapon_mod_slots WHERE weapon_id=? ORDER BY tier,slot_type')).all(id)
} : null;
await database.exec('BEGIN');
try {
if (isExotic && exoticTalentId) {
if (existingExoticTalentId) {
await (await database.prepare("UPDATE talents SET name=?,category='Weapon',cooldown_seconds=?,duration_seconds=?,max_stacks=?,active=1,updated_at=? WHERE id=?")).run(exoticTalentName, exoticCooldownSeconds, exoticDurationSeconds, exoticMaxStacks, now, exoticTalentId);
} else {
await (await database.prepare("INSERT INTO talents(id,name,category,cooldown_seconds,duration_seconds,max_stacks,active,created_at,updated_at) VALUES(?,?,'Weapon',?,?,?,1,?,?)")).run(exoticTalentId, exoticTalentName, exoticCooldownSeconds, exoticDurationSeconds, exoticMaxStacks, now, now);
}
await (await database.prepare(`INSERT INTO talent_weapon_scopes(talent_id,scope) VALUES(?,'Exotic')
ON CONFLICT(talent_id) DO UPDATE SET scope='Exotic'`)).run(exoticTalentId);
await (await database.prepare('DELETE FROM weapon_talent_slots WHERE talent_id=?')).run(exoticTalentId);
await (await database.prepare(`INSERT INTO talent_description_templates(talent_id,template,updated_at) VALUES(?,?,?)
ON CONFLICT(talent_id) DO UPDATE SET template=excluded.template,updated_at=excluded.updated_at`)).run(exoticTalentId, exoticDescriptionTemplate, now);
const effectStatIds = exoticEffects.map((effect) => effect.statId).filter((value): value is string => Boolean(value));
const unitRows = effectStatIds.length
? await (await database.prepare(`SELECT id,unit FROM stat_attributes WHERE id IN (${effectStatIds.map(() => '?').join(',')})`)).all(...effectStatIds)
: [];
const units = new Map(unitRows.map((row) => [String(row.id), row.unit]));
for (const talentTier of ['I', 'II'] as const) {
const description = renderTalentDescription(exoticDescriptionTemplate, exoticEffects, units, talentTier, exoticDurationSeconds, exoticMaxStacks);
await (await database.prepare(`INSERT INTO talent_tiers(id,talent_id,tier,description,trigger_text,effect_text,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(talent_id,tier) DO UPDATE SET description=excluded.description,updated_at=excluded.updated_at`))
.run(randomUUID(), exoticTalentId, talentTier, description, '', '', now, now);
const tierRow = await (await database.prepare('SELECT id FROM talent_tiers WHERE talent_id=? AND tier=?')).get(exoticTalentId, talentTier);
const tierId = String(tierRow?.id);
await (await database.prepare('DELETE FROM talent_tier_effects WHERE talent_tier_id=?')).run(tierId);
for (const effect of exoticEffects.filter((outcome) => outcome.statId)) {
await (await database.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)'))
.run(randomUUID(), tierId, effect.statId, talentTier === 'I' ? effect.valueI : effect.valueII, now);
}
}
await saveConditionalRuleInTransaction(database, 'talent', exoticTalentId, exoticConditionalRule!);
await applyIconMutation(database, 'talent', exoticTalentId, exoticIconMutation, operator.id);
}
if (id) {
await (await database.prepare(`UPDATE weapons SET name=?,category=?,is_exotic=?,native_tier=?,damage_type=?,rpm=?,magazine=?,
reload_speed=?,accuracy=?,stability=?,exotic_talent_id=?,active=1,updated_at=? WHERE id=?`))
.run(name, category, isExotic ? 1 : 0, nativeTier, damageType, fixed.rpm, fixed.magazine, fixed.reloadSpeed, fixed.accuracy, fixed.stability, exoticTalentId, now, id);
.run(name, category, isExotic ? 1 : 0, nativeTier, damageType, fixed.rpm, fixed.magazine, fixed.reloadSpeed, fixed.accuracy, fixed.stability, isExotic ? exoticTalentId : null, now, id);
} else {
await (await database.prepare(`INSERT INTO weapons(id,name,category,is_exotic,native_tier,damage_type,rpm,magazine,reload_speed,
accuracy,stability,exotic_talent_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`))
.run(weaponId, name, category, isExotic ? 1 : 0, nativeTier, damageType, fixed.rpm, fixed.magazine, fixed.reloadSpeed, fixed.accuracy, fixed.stability, exoticTalentId, now, now);
.run(weaponId, name, category, isExotic ? 1 : 0, nativeTier, damageType, fixed.rpm, fixed.magazine, fixed.reloadSpeed, fixed.accuracy, fixed.stability, isExotic ? exoticTalentId : null, now, now);
}
const previousExoticTalentId = typeof before?.record?.exotic_talent_id === 'string' ? before.record.exotic_talent_id : '';
if (!isExotic && previousExoticTalentId) {
const anotherOwner = await (await database.prepare('SELECT id FROM weapons WHERE exotic_talent_id=? AND id<>?')).get(previousExoticTalentId, weaponId);
if (!anotherOwner) await (await database.prepare('UPDATE talents SET active=0,updated_at=? WHERE id=?')).run(now, previousExoticTalentId);
}
await (await database.prepare('DELETE FROM weapon_tier_ranges WHERE weapon_id=?')).run(weaponId);
await (await database.prepare('DELETE FROM weapon_tier_ranges WHERE weapon_id=?')).run(weaponId);
for (const tier of tiers) {
const core = sharedCoreRanges.find((range) => Number(range.tier) === tier.tier);
await (await database.prepare(`INSERT INTO weapon_tier_ranges(weapon_id,tier,damage_min,damage_max,firepower_min,
@@ -488,7 +377,7 @@ export const actions: Actions = {
await (await database.prepare('INSERT INTO weapon_augmentation_effects(id,weapon_id,stat_attribute_id,value,sort_order) VALUES(?,?,?,?,?)'))
.run(randomUUID(), weaponId, effect.statId, effect.value, effect.order);
}
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, modSlots, talent1Ids, talent2Ids, augmentEffects, exoticTalent: isExotic ? { id: exoticTalentId, name: exoticTalentName, icon: await gameDataIconMetadata(database, 'talent', String(exoticTalentId)), descriptionTemplate: exoticDescriptionTemplate, conditional: await conditionalRuleSnapshot(database, 'talent', String(exoticTalentId)) } : null } });
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, modSlots, talent1Ids, talent2Ids, augmentEffects, exoticTalent: selectedExoticTalent ? { id: String(selectedExoticTalent.id), name: String(selectedExoticTalent.name), tierIIDocumented: Boolean(selectedExoticTalent.tier_ii_documented) } : null } });
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
+30 -50
View File
@@ -7,10 +7,7 @@
import KpiFilters from '$lib/components/KpiFilters.svelte';
import GameIcon from '$lib/components/GameIcon.svelte';
import type { GameIconName } from '$lib/game-icons';
import IconUploadField from '$lib/components/IconUploadField.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import { WEAPON_CATEGORIES } from '$lib/weapons/category-bonuses';
import {
excludedTierZeroWeaponModSlot,
@@ -26,16 +23,10 @@
const iconName = (value: string): GameIconName => value.toLowerCase() as GameIconName;
const damageTypeOptions = damageTypes.map((value) => ({ value, label: value, icon: iconName(value), iconOnly: true }));
const blankTier = (tier: number) => ({ tier, damage_min: 0, damage_max: 0 });
const blankConditionalRule = (): ConditionalMechanicDraftV1 => ({
version:1,simulationReadiness:'captured_only',scenarioInputs:[],pools:[],
clauses:[{key:'primary',label:'Primary',event:'weapon_hit',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'equipped_weapon',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{I:{value:0},II:{value:0}}}]}]
});
const blankExoticTalent = () => ({ id: '', name: '', iconUrl: '', descriptionTemplate: '', conditionalRule: blankConditionalRule() });
const blank = () => ({ id: '', name: '', category: 'AR', is_exotic: 0, native_tier: '0', damage_type: 'Pierce', rpm: 0, magazine: 0, reload_speed: 0, accuracy: 0, stability: 0, exotic_talent_id: '', exoticTalent: blankExoticTalent(), active: 1, tiers: [blankTier(0), blankTier(1), blankTier(2)], modSlots: [0,1,2].map((tier) => ({ tier, slots: [] })), sharedModSlots: [], t0ExcludedSlot: '', talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] });
const blank = () => ({ id: '', name: '', category: 'AR', is_exotic: 0, native_tier: '0', damage_type: 'Pierce', rpm: 0, magazine: 0, reload_speed: 0, accuracy: 0, stability: 0, exotic_talent_id: '', active: 1, tiers: [blankTier(0), blankTier(1), blankTier(2)], modSlots: [0,1,2].map((tier) => ({ tier, slots: [] })), sharedModSlots: [], t0ExcludedSlot: '', talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] });
let editing = $state<any | null>(null);
let bonusEditing = $state<any | null>(null);
let handledResultToken = $state('');
let iconResetToken = $state(0);
let openedRequestedWeapon = $state(false);
const edit = (weapon: any) => {
const tiers = [0, 1, 2].map((tier) => {
@@ -44,7 +35,7 @@
});
const modSlots = [0,1,2].map((tier) => ({ tier, slots: [...(weapon.modSlots?.find((row: any) => Number(row.tier) === tier)?.slots ?? [])] }));
const sharedSlots = sharedWeaponModSlots(modSlots);
editing = { ...weapon, native_tier: String(weapon.native_tier), tiers, modSlots, sharedModSlots: sharedSlots, t0ExcludedSlot: excludedTierZeroWeaponModSlot(sharedSlots, modSlots), talent1Ids: [...weapon.talent1Ids], talent2Ids: [...weapon.talent2Ids], augmentEffects: weapon.augmentEffects.map((effect: any) => ({ ...effect })), exoticTalent: weapon.exoticTalent ? { ...weapon.exoticTalent, conditionalRule: weapon.exoticTalent.conditionalRule ?? blankConditionalRule() } : blankExoticTalent() };
editing = { ...weapon, native_tier: String(weapon.native_tier), tiers, modSlots, sharedModSlots: sharedSlots, t0ExcludedSlot: excludedTierZeroWeaponModSlot(sharedSlots, modSlots), talent1Ids: [...weapon.talent1Ids], talent2Ids: [...weapon.talent2Ids], augmentEffects: weapon.augmentEffects.map((effect: any) => ({ ...effect })) };
bonusEditing = null;
};
const editBonusProfile = (profile: any) => {
@@ -70,25 +61,20 @@
const setExotic = (isExotic: boolean) => {
if (!editing) return;
editing.is_exotic = isExotic ? 1 : 0;
if (isExotic && !editing.exoticTalent) editing.exoticTalent = blankExoticTalent();
if (!isExotic) editing.exotic_talent_id = '';
};
const mechanicValue = (value: string | number, singular: string) => `${value} ${Number(value) === 1 ? singular : `${singular}s`}`;
const exoticOutcomeValue = (effect: any, tier: 'I' | 'II') => {
const value = effect?.variants?.[tier]?.value;
if (value === '' || value === null || value === undefined) return '';
if (effect.kind !== 'attribute_modifier') return String(value);
const stat = data.stats.find((item) => item.id === effect.attributeId);
const unit = stat?.unit === 'flat' ? '' : stat?.unit ?? '';
return unit === 'Shots' ? `${value} ${unit}` : `${value}${unit}`;
};
const exoticPreview = (tier: 'I' | 'II') => editing.exoticTalent.descriptionTemplate.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => {
const duration = editing.exoticTalent.conditionalRule.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds;
const maxStacks = editing.exoticTalent.conditionalRule.pools.length ? Math.max(...editing.exoticTalent.conditionalRule.pools.map((pool: any) => pool.max ?? 1)) : null;
if (expression === 'duration') return duration == null ? placeholder : mechanicValue(duration, 'second');
if (expression === 'max_stacks') return maxStacks == null ? placeholder : mechanicValue(maxStacks, 'stack');
const available = editing.exoticTalent.conditionalRule.clauses.flatMap((clause: any) => clause.outcomes);
const value = exoticOutcomeValue(available.find((outcome: any) => outcome.descriptionSlot === Number(expression)), tier);
return value || placeholder;
const exoticTalentOptions = () => data.exoticTalentOptions.map((talent: any) => {
const assignedElsewhere = Boolean(talent.ownerWeaponId && talent.ownerWeaponId !== editing?.id);
return {
...talent,
detail: `${talent.ownerWeaponName
? `ASSIGNED TO ${String(talent.ownerWeaponName).toLocaleUpperCase()}`
: 'AVAILABLE FOR ASSIGNMENT'} · TIER II ${talent.tierIIDocumented ? 'DOCUMENTED' : 'NOT YET DOCUMENTED'}`,
disabled: assignedElsewhere,
disabledReason: assignedElsewhere
? `Already assigned to ${talent.ownerWeaponName}`
: ''
};
});
const addAugmentEffect = () => {
if (editing && editing.augmentEffects.length < 2) editing.augmentEffects = [...editing.augmentEffects, { stat_attribute_id: '', value: 0, sort_order: 2 }];
@@ -105,10 +91,9 @@
if (requested) edit(requested);
openedRequestedWeapon = true;
}
if (form?.weaponSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
if (form.weaponMode === 'add') iconResetToken += 1;
editing = form.weaponMode === 'add' ? blank() : null;
if (form?.weaponSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
editing = form.weaponMode === 'add' ? blank() : null;
}
if (form?.weaponBonusProfileSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
@@ -162,24 +147,19 @@
<label class="field exotic-toggle">EXOTIC STATUS<span title="Exotic weapon"><input name="isExotic" type="checkbox" value="1" checked={Number(editing.is_exotic) === 1} onchange={(event) => setExotic(event.currentTarget.checked)}/><GameIcon name="exotic" decorative/><b class="sr-only">EXOTIC WEAPON</b></span></label>
</div>
{#if Number(editing.is_exotic)}
<input type="hidden" name="exoticTalentId" value={editing.exoticTalent.id}/>
<input type="hidden" name="exoticConditionalRule" value={JSON.stringify(editing.exoticTalent.conditionalRule)}/>
<section class="weapon-exotic-talent">
<div class="selection-head"><div><span>DEDICATED EXOTIC TALENT</span><small>OWNED EXCLUSIVELY BY THIS WEAPON · TIER II VALUES REQUIRED</small></div><b>{editing.exoticTalent.id ? 'LINKED RECORD' : 'NEW RECORD'}</b></div>
<div class="exotic-icon-identity"><IconUploadField currentUrl={editing.exoticTalent.iconUrl} label="EXOTIC TALENT ICON" resetToken={iconResetToken}/><div class="form-grid exotic-talent-identity"><label class="field">TALENT NAME<input name="exoticTalentName" bind:value={editing.exoticTalent.name} maxlength="80" required/></label><label class="field">SHARED DESCRIPTION TEMPLATE<textarea name="exoticDescriptionTemplate" bind:value={editing.exoticTalent.descriptionTemplate} placeholder={'Hits grant +{1} for {duration}…'} required></textarea><small>Use numbered expressions for effect values and named expressions for duration and stacks.</small></label></div></div>
<p class="talent-template-note">DESCRIPTION EXPRESSIONS · {'{duration}'} and {'{max_stacks}'} render the defined mechanics. Cooldown is displayed separately as a standardized Talent Cooldown line.</p>
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{exoticPreview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{exoticPreview('II') || 'Description preview'}</p></div></div>
<MechanicProgramEditor
attributes={data.stats}
valueColumns={[{key:'tierI',variantKey:'I',label:'TIER I'},{key:'tierII',variantKey:'II',label:'TIER II'}]}
idPrefix={`weapon-exotic-${editing.exoticTalent.id || 'new'}`}
aiContext={{
sourceKind: 'exotic_weapon_talent',
sourceName: editing?.exoticTalent?.name ?? '',
sourceDescription: editing?.exoticTalent?.descriptionTemplate ?? ''
}}
bind:rule={editing.exoticTalent.conditionalRule}
<div class="selection-head"><div><span>DEDICATED EXOTIC TALENT</span><small>SELECT ONE TALENT FROM THE EXOTIC WEAPON REGISTRY</small></div><b>{editing.exotic_talent_id ? '1 / 1 ASSIGNED' : 'REQUIRED'}</b></div>
<SearchableAssignment
items={exoticTalentOptions()}
fieldName="exoticTalentId"
mode="single"
subjectLabel="EXOTIC TALENT"
searchPlaceholder="Search exotic talents…"
emptySelectionLabel="NO EXOTIC TALENT ASSIGNED"
bind:value={editing.exotic_talent_id}
/>
<p class="weapon-mod-slot-note"><b>REFERENCE ONLY</b><span>Create and model exotic talents in the Talent registry. An exotic talent can belong to one weapon, and Tier II may remain not yet documented.</span></p>
{#if data.exoticTalentOptions.length === 0}<p class="error">Create an active Exotic Weapon talent in the Talent registry before saving this weapon.</p>{/if}
</section>
{/if}
<div class="selection-head"><div><span>FIXED WEAPON ATTRIBUTES</span><small>UNCHANGED ACROSS TIERS</small></div><b>BASE HANDLING</b></div>
@@ -224,7 +204,7 @@
{#if data.talent1Options.length === 0 || data.talent2Options.length === 0}<p class="error">Create and classify at least one active Talent 1 and Talent 2 weapon talent before adding weapons.</p>{/if}
{#if data.stats.length === 0}<p class="error">Create an active attribute before configuring tactical augmentation 01.</p>{/if}
{#if form?.weaponError}<p class="error">{form.weaponError}</p>{/if}
<div class="form-actions split-actions"><div>{#if editing.id}<button class="moderation-action danger weapon-archive-action" formaction="?/archive" name="id" value={editing.id}><b aria-hidden="true">⌁</b> ARCHIVE WEAPON</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={data.talent1Options.length === 0 || data.talent2Options.length === 0 || data.stats.length === 0}>SAVE WEAPON →</button></div></div>
<div class="form-actions split-actions"><div>{#if editing.id}<button class="moderation-action danger weapon-archive-action" formaction="?/archive" name="id" value={editing.id}><b aria-hidden="true">⌁</b> ARCHIVE WEAPON</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={data.talent1Options.length === 0 || data.talent2Options.length === 0 || data.stats.length === 0 || (Number(editing.is_exotic) === 1 && !editing.exotic_talent_id)}>SAVE WEAPON →</button></div></div>
</PendingForm>
</div>
{/snippet}