Adding skills & isac talent updates
This commit is contained in:
+22
-19
File diff suppressed because one or more lines are too long
@@ -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
@@ -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.'
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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.' }
|
||||
];
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user