Update mechanic authoring persistence
This commit is contained in:
+34
-11
@@ -2390,9 +2390,11 @@ display `ADVANCED`, and stay visible in the generated script. State rows expose
|
|||||||
label, agent or target scope, initial value, bounds, best-case value, and
|
label, agent or target scope, initial value, bounds, best-case value, and
|
||||||
optional expiry. A state change that writes a target pool uses a `STATE OWNER`
|
optional expiry. A state change that writes a target pool uses a `STATE OWNER`
|
||||||
segmented control with `EVENT TARGET` and `EVENT ACTOR`; the selected owner
|
segmented control with `EVENT TARGET` and `EVENT ACTOR`; the selected owner
|
||||||
remains visible in the generated script. Outcome rows expose kind, target scope,
|
remains visible in the generated script. The `DERIVED VALUE` operation writes
|
||||||
Attribute or resource when applicable, value basis, state scaling, phase, and
|
the branch reducer's numeric result into the selected pool and displays that
|
||||||
every source-specific value column. The Value Model control supports static
|
link as a text readout instead of a numeric input. Outcome rows expose kind,
|
||||||
|
target scope, Attribute or resource when applicable, value basis, state scaling,
|
||||||
|
phase, and every source-specific value column. The Value Model control supports
|
||||||
values, live Attribute totals, percentage-normalized Attribute values, event
|
values, live Attribute totals, percentage-normalized Attribute values, event
|
||||||
fields, reducer output, source-defined scenario controls, and OS Protocol
|
fields, reducer output, source-defined scenario controls, and OS Protocol
|
||||||
upgrade scaling where the source permits it. Selecting either Attribute model
|
upgrade scaling where the source permits it. Selecting either Attribute model
|
||||||
@@ -2404,6 +2406,15 @@ applied to the stored value. Restoration outcomes also expose an `INSTANT` or
|
|||||||
field and describes the configured value as the total delivered across that
|
field and describes the configured value as the total delivered across that
|
||||||
duration; switching back to instant clears the duration.
|
duration; switching back to instant clears the duration.
|
||||||
|
|
||||||
|
Selecting `DERIVE A SCALAR VALUE` exposes a `DERIVED VALUE SOURCE`
|
||||||
|
`SegmentedChoice`. `STATIC` shows the numeric value input. `EVENT FIELD` shows
|
||||||
|
the numeric fields registered for the branch event, including the proximity
|
||||||
|
snapshot's allied-Agent count; it never asks the author to type a field path.
|
||||||
|
Keep an existing expression that falls outside these manual patterns visible as
|
||||||
|
disabled `ADVANCED` data. The source and field controls retain `SegmentedChoice`
|
||||||
|
radio semantics, visible legends, arrow-key operation, focus-visible treatment,
|
||||||
|
and text labels.
|
||||||
|
|
||||||
Target-scoped direct-damage outcomes offer `HIT-TARGET EXPLOSION` in the Area
|
Target-scoped direct-damage outcomes offer `HIT-TARGET EXPLOSION` in the Area
|
||||||
Targeting control. Selecting it creates an event-target-centered enemy area with
|
Targeting control. Selecting it creates an event-target-centered enemy area with
|
||||||
a five-meter starting radius, reveals the required radius field, and shows a
|
a five-meter starting radius, reveals the required radius field, and shows a
|
||||||
@@ -2421,13 +2432,15 @@ source program, where authors may edit its exposed branch, state, and outcome
|
|||||||
controls. The recipe label records provenance only. `REPLACE PROGRAM` requires
|
controls. The recipe label records provenance only. `REPLACE PROGRAM` requires
|
||||||
an inline confirmation that states all existing branches will be discarded.
|
an inline confirmation that states all existing branches will be discarded.
|
||||||
|
|
||||||
Builder Controls define source-specific scenario assumptions for the planned
|
Builder Controls define source-specific scenario assumptions for player
|
||||||
player simulation. ISAC authors choose toggle, slider, or numeric input and
|
simulation. ISAC authors choose toggle, slider, or numeric input and provide a
|
||||||
provide a label, Agent guidance, default value, best-case value, and numeric
|
label, Agent guidance, default value, best-case value, and numeric bounds when
|
||||||
bounds when applicable. The current player UI does not render these controls or
|
applicable. The calculation timeline builder can consume values supplied for
|
||||||
read their saved values. It uses an explicit `COMING SOON` panel for the future
|
these controls, including `allies-within-30m-with-agent-granted-buff`. Current
|
||||||
advanced scenario surface. The authored contract still requires the runtime to
|
Builder and shared Build pages do not render the controls or let players change
|
||||||
report `REQUIRES ASSUMPTIONS` when a required cadence or environmental value is
|
those values. They use an explicit `COMING SOON` panel for the future advanced
|
||||||
|
scenario surface. The authored contract still requires the runtime to report
|
||||||
|
`REQUIRES ASSUMPTIONS` when a required cadence or environmental value is
|
||||||
missing; the editor and runtime never invent a cadence.
|
missing; the editor and runtime never invent a cadence.
|
||||||
|
|
||||||
Use `--shd-model-surface` and `--shd-model-border` for mechanic programs,
|
Use `--shd-model-surface` and `--shd-model-border` for mechanic programs,
|
||||||
@@ -2478,7 +2491,10 @@ The panel presents five explicit states:
|
|||||||
polite live region, and disables repeat actions.
|
polite live region, and disables repeat actions.
|
||||||
- `CLARIFICATION REQUIRED` lists each focused question with its reason and
|
- `CLARIFICATION REQUIRED` lists each focused question with its reason and
|
||||||
bounded answer control. The author may answer, revise the source text, or
|
bounded answer control. The author may answer, revise the source text, or
|
||||||
return to manual authoring.
|
return to manual authoring. For an unresolved numbered numeric placeholder,
|
||||||
|
show a required clarification for every source value column whose number is
|
||||||
|
missing. Do not prefill, infer, or suggest a percentage. Keep
|
||||||
|
`APPLY TO EDITOR` unavailable until the author answers each required variant.
|
||||||
- `CAPABILITY GAP` names the unsupported behavior and displays the developer
|
- `CAPABILITY GAP` names the unsupported behavior and displays the developer
|
||||||
handoff, supported approximation, and optional workaround in separate labeled
|
handoff, supported approximation, and optional workaround in separate labeled
|
||||||
blocks. Do not present a capability gap as validation failure.
|
blocks. Do not present a capability gap as validation failure.
|
||||||
@@ -2502,6 +2518,13 @@ applying the draft would replace configured branches, pools, controls, or
|
|||||||
outcomes. The standard ISAC save lifecycle begins only after the author reviews
|
outcomes. The standard ISAC save lifecycle begins only after the author reviews
|
||||||
and saves the resulting form.
|
and saves the resulting form.
|
||||||
|
|
||||||
|
An applied AI draft stores each clarified number in the corresponding
|
||||||
|
human-facing outcome-variant value. A scaling expression may retain the
|
||||||
|
draft-only `variant_value` reference so later saves preserve the link to that
|
||||||
|
displayed value. The runtime adapter resolves the reference separately for each
|
||||||
|
variant. Show the value and generated formula in the ordinary review controls;
|
||||||
|
do not require an author to inspect or edit raw JSON.
|
||||||
|
|
||||||
Use text labels with status colors. Orange marks Generate, Answer, and Apply
|
Use text labels with status colors. Orange marks Generate, Answer, and Apply
|
||||||
actions. Blue identifies modeled information, yellow marks assumptions or
|
actions. Blue identifies modeled information, yellow marks assumptions or
|
||||||
unsupported coverage, and red marks request or validation failures. Never use
|
unsupported coverage, and red marks request or validation failures. Never use
|
||||||
|
|||||||
@@ -79,6 +79,24 @@ meters:
|
|||||||
duration.
|
duration.
|
||||||
- The wearer does not count as an ally.
|
- The wearer does not count as an ally.
|
||||||
|
|
||||||
|
## Close and Personal
|
||||||
|
|
||||||
|
Close and Personal uses a proximity-snapshot qualifying set:
|
||||||
|
|
||||||
|
- Include each distinct allied Agent whose distance from the source Agent is
|
||||||
|
less than or equal to 30 meters and who has at least one buff granted by that
|
||||||
|
source Agent.
|
||||||
|
- Count an ally once when several of the source Agent's buffs qualify.
|
||||||
|
- Do not include an ally whose qualifying buffs come only from other sources.
|
||||||
|
|
||||||
|
Record the resolved non-negative integer count in
|
||||||
|
`proximity_snapshot.values.alliesWithin30MetersWithAgentGrantedBuff`. The
|
||||||
|
calculation builder supplies it from the
|
||||||
|
`allies-within-30m-with-agent-granted-buff` scenario input. The confirmed rule
|
||||||
|
defines this qualifying set but does not provide Close and Personal's base
|
||||||
|
percentage. Keep that percentage unresolved until source evidence or an author's
|
||||||
|
clarification supplies it.
|
||||||
|
|
||||||
## Emergency Retreat
|
## Emergency Retreat
|
||||||
|
|
||||||
Emergency Retreat activates whenever at least one enemy is within 20 meters and
|
Emergency Retreat activates whenever at least one enemy is within 20 meters and
|
||||||
|
|||||||
+43
-13
@@ -638,12 +638,24 @@ normalized events are `shot_fired`, `weapon_hit`, `skill_deployed`,
|
|||||||
`weapon_swapped`, and `tick`. Event details such as target, distance, critical
|
`weapon_swapped`, and `tick`. Event details such as target, distance, critical
|
||||||
state, source, movement, and nearby counts travel as typed event values. The
|
state, source, movement, and nearby counts travel as typed event values. The
|
||||||
event registry publishes authoritative field metadata where branches need a
|
event registry publishes authoritative field metadata where branches need a
|
||||||
bounded contract. For those events, the mechanic editor derives its event-field
|
bounded contract. For those events, the mechanic editor derives event-field
|
||||||
controls from that metadata. For example, `weapon_hit.values.headshot` is a
|
conditions and manual derived-scalar controls from that metadata.
|
||||||
boolean that distinguishes headshots from body shots. `enemy_killed` and
|
`proximity_snapshot.values.alliesWithin30MetersWithAgentGrantedBuff` is an
|
||||||
`assist` may carry `values.assignedAbilityActive`; a Skill Chip Talent uses that
|
optional non-negative integer. It contains the resolved count of distinct allied
|
||||||
boolean to restrict a kill or assist trigger to its assigned ability's active
|
Agents at a distance less than or equal to 30 meters who have at least one buff
|
||||||
period. `skill_deployed` and `signature_ability_deployed` may carry
|
granted by the source Agent. Several qualifying buffs on one ally count once.
|
||||||
|
`proximity_snapshot` remains non-strict, so saved mechanic data and producers
|
||||||
|
that omit the field remain compatible.
|
||||||
|
|
||||||
|
`src/lib/builder/loadout-combat.ts` bridges the calculation scenario input
|
||||||
|
`allies-within-30m-with-agent-granted-buff` into that field on each generated
|
||||||
|
`proximity_snapshot`. For example, a derived-scalar reducer can select the
|
||||||
|
registered numeric field without requiring a hand-entered path. Other registered
|
||||||
|
metadata includes `weapon_hit.values.headshot`, a boolean that distinguishes
|
||||||
|
headshots from body shots. `enemy_killed` and `assist` may carry
|
||||||
|
`values.assignedAbilityActive`; a Skill Chip Talent uses that boolean to
|
||||||
|
restrict a kill or assist trigger to its assigned ability's active period.
|
||||||
|
`skill_deployed` and `signature_ability_deployed` may carry
|
||||||
`values.assignedAbilityDurationSeconds` to start assigned-ability state. The
|
`values.assignedAbilityDurationSeconds` to start assigned-ability state. The
|
||||||
runtime expires that state before it evaluates another event at the same
|
runtime expires that state before it evaluates another event at the same
|
||||||
timestamp.
|
timestamp.
|
||||||
@@ -707,11 +719,20 @@ The program graph uses three child tables:
|
|||||||
rolls separately. Restoration outcomes may deliver their total immediately or
|
rolls separately. Restoration outcomes may deliver their total immediately or
|
||||||
continuously over an explicit duration.
|
continuously over an explicit duration.
|
||||||
|
|
||||||
Target-scoped direct damage may use `event_target_area` targeting for an
|
Draft outcome expressions may use `{"ref":{"source":"variant_value"}}`. This
|
||||||
explosion around its event target. This targeting selects valid enemies and
|
reference belongs to the conditional draft grammar and does not enter the
|
||||||
stores a positive radius in meters. It is valid only for `on_event` or
|
runtime expression grammar. Each persisted outcome-variant row keeps its
|
||||||
`after_event` outcomes. The legacy `within_radius` shape remains descriptive
|
human-facing numeric `value`, while `value_expression_json` preserves the
|
||||||
targeting metadata and does not select runtime entities.
|
reference and its semantic link. The adapters in
|
||||||
|
`src/lib/calculations/mechanic-editor.ts` and
|
||||||
|
`src/lib/calculations/mechanic-persistence.ts` resolve the reference from the
|
||||||
|
current row's `value` when they construct a runtime program.
|
||||||
|
|
||||||
|
Target-scoped direct damage may use `event_target_area` targeting for an
|
||||||
|
explosion around its event target. This targeting selects valid enemies and
|
||||||
|
stores a positive radius in meters. It is valid only for `on_event` or
|
||||||
|
`after_event` outcomes. The legacy `within_radius` shape remains descriptive
|
||||||
|
targeting metadata and does not select runtime entities.
|
||||||
|
|
||||||
The generic runtime in `src/lib/calculations/mechanic.ts` processes combat
|
The generic runtime in `src/lib/calculations/mechanic.ts` processes combat
|
||||||
events in timestamp order. Predicates decide whether an event enters a clause.
|
events in timestamp order. Predicates decide whether an event enters a clause.
|
||||||
@@ -800,6 +821,13 @@ descriptions, and calculation metadata. The client cannot replace the system
|
|||||||
instructions, choose arbitrary models, attach files, add tools, or add external
|
instructions, choose arbitrary models, attach files, add tools, or add external
|
||||||
URLs.
|
URLs.
|
||||||
|
|
||||||
|
When source wording contains an unresolved numbered numeric placeholder, the
|
||||||
|
assistant must return `clarification_required` and ask for the value of every
|
||||||
|
required source variant. It must not infer a percentage or other numeric value.
|
||||||
|
After clarification, the draft places each answer in the corresponding
|
||||||
|
outcome-variant `value`; any scaling expression uses the draft-only
|
||||||
|
`variant_value` reference instead of repeating that number.
|
||||||
|
|
||||||
The deterministic draft parser owns the canonical outcome-capability manifest.
|
The deterministic draft parser owns the canonical outcome-capability manifest.
|
||||||
The AI context serializes that manifest instead of maintaining a second outcome
|
The AI context serializes that manifest instead of maintaining a second outcome
|
||||||
grammar. The parser rejects outcome fields absent from the manifest. Contract
|
grammar. The parser rejects outcome fields absent from the manifest. Contract
|
||||||
@@ -896,8 +924,10 @@ events remain the evidence for revision saves and activation.
|
|||||||
Source authors define planned player scenario controls as typed toggle, slider,
|
Source authors define planned player scenario controls as typed toggle, slider,
|
||||||
or numeric inputs. Each input records its label, guidance, bounds when
|
or numeric inputs. Each input records its label, guidance, bounds when
|
||||||
applicable, default value, and best-case value. ISAC retains these definitions
|
applicable, default value, and best-case value. ISAC retains these definitions
|
||||||
for continued mechanic authoring. Current Builder and shared Build pages do not
|
for continued mechanic authoring, and the calculation timeline builder can
|
||||||
render or evaluate them. Their calculation panels display `COMING SOON` instead.
|
consume scenario values supplied by calculation orchestration. Current Builder
|
||||||
|
and shared Build pages do not expose those controls or invoke the simulation.
|
||||||
|
Their calculation panels display `COMING SOON` instead.
|
||||||
|
|
||||||
Loadout schema version 6 stores one focus, selected tree ranks, nine Skill
|
Loadout schema version 6 stores one focus, selected tree ranks, nine Skill
|
||||||
Chips, independent T1/T2 choices for each equipped gear and weapon Talent, a
|
Chips, independent T1/T2 choices for each equipped gear and weapon Talent, a
|
||||||
|
|||||||
@@ -176,6 +176,38 @@ describe('loadout mechanic timeline', () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('emits the Agent-granted-buff ally count from its exact scenario input and defaults it to zero', () => {
|
||||||
|
const profile = createDraft().combatProfile;
|
||||||
|
profile.encounterSeconds = 1;
|
||||||
|
const configured = buildWeaponMechanicTimeline(
|
||||||
|
{ rpm: 60, magazine: 100, reload_speed: 0 },
|
||||||
|
profile,
|
||||||
|
0,
|
||||||
|
{
|
||||||
|
scenarioInputs: {
|
||||||
|
'allies-within-30m-with-agent-granted-buff': 4,
|
||||||
|
'allies-within-radius': 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
).filter((event) => event.kind === 'proximity_snapshot');
|
||||||
|
const defaults = buildWeaponMechanicTimeline(
|
||||||
|
{ rpm: 60, magazine: 100, reload_speed: 0 },
|
||||||
|
profile,
|
||||||
|
).filter((event) => event.kind === 'proximity_snapshot');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
configured.map(
|
||||||
|
(event) => event.values?.alliesWithin30MetersWithAgentGrantedBuff,
|
||||||
|
),
|
||||||
|
).toEqual([4, 4]);
|
||||||
|
expect(configured.map((event) => event.values?.allyCount)).toEqual([2, 2]);
|
||||||
|
expect(
|
||||||
|
defaults.map(
|
||||||
|
(event) => event.values?.alliesWithin30MetersWithAgentGrantedBuff,
|
||||||
|
),
|
||||||
|
).toEqual([0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
it('seeds Extra Health state before periodic stack evaluation', () => {
|
it('seeds Extra Health state before periodic stack evaluation', () => {
|
||||||
const profile = createDraft().combatProfile;
|
const profile = createDraft().combatProfile;
|
||||||
profile.encounterSeconds = 2;
|
profile.encounterSeconds = 2;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
} from '$lib/calculations/combat';
|
} from '$lib/calculations/combat';
|
||||||
import {
|
import {
|
||||||
evaluateMechanicProgram,
|
evaluateMechanicProgram,
|
||||||
|
PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_VALUE_KEY,
|
||||||
|
PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_INPUT,
|
||||||
type MechanicCombatContext,
|
type MechanicCombatContext,
|
||||||
type MechanicEvaluation,
|
type MechanicEvaluation,
|
||||||
type MechanicEvent,
|
type MechanicEvent,
|
||||||
@@ -803,6 +805,11 @@ export function buildWeaponMechanicTimeline(
|
|||||||
allyCount: Number(
|
allyCount: Number(
|
||||||
options.scenarioInputs?.['allies-within-radius'] ?? 0,
|
options.scenarioInputs?.['allies-within-radius'] ?? 0,
|
||||||
),
|
),
|
||||||
|
[PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_VALUE_KEY]: Number(
|
||||||
|
options.scenarioInputs?.[
|
||||||
|
PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_INPUT
|
||||||
|
] ?? 0,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
events.push({
|
events.push({
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
currentHealthPercentBandPredicate,
|
currentHealthPercentBandPredicate,
|
||||||
eventFieldComparisonPredicate,
|
eventFieldComparisonPredicate,
|
||||||
|
eventFieldNumericExpression,
|
||||||
mechanicTargetingForMode,
|
mechanicTargetingForMode,
|
||||||
mechanicTargetingMode,
|
mechanicTargetingMode,
|
||||||
parseConditionalMechanicDraft,
|
parseConditionalMechanicDraft,
|
||||||
readCurrentHealthPercentBand,
|
readCurrentHealthPercentBand,
|
||||||
readEventFieldComparison,
|
readEventFieldComparison,
|
||||||
|
readEventFieldNumericExpression,
|
||||||
registeredEventFields,
|
registeredEventFields,
|
||||||
readStatePoolThreshold,
|
readStatePoolThreshold,
|
||||||
statePoolThresholdPredicate,
|
statePoolThresholdPredicate,
|
||||||
@@ -202,6 +204,108 @@ describe('conditional mechanic authoring payload', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('round-trips the registered proximity count as a derived scalar and resolves each variant display value', () => {
|
||||||
|
const path = 'values.alliesWithin30MetersWithAgentGrantedBuff';
|
||||||
|
expect(registeredEventFields('proximity_snapshot')).toContainEqual({
|
||||||
|
path,
|
||||||
|
label: 'Allies within 30 meters with an Agent-granted buff',
|
||||||
|
type: 'number',
|
||||||
|
minimum: 0,
|
||||||
|
integer: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const expression = eventFieldNumericExpression(path);
|
||||||
|
expect(readEventFieldNumericExpression(expression)).toBe(path);
|
||||||
|
expect(
|
||||||
|
readEventFieldNumericExpression({
|
||||||
|
op: 'multiply',
|
||||||
|
left: expression,
|
||||||
|
right: 2,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
const value = draft();
|
||||||
|
value.clauses[0].event = 'proximity_snapshot';
|
||||||
|
value.clauses[0].recipeKey = undefined;
|
||||||
|
value.clauses[0].reducer = {
|
||||||
|
kind: 'derived_scalar',
|
||||||
|
value: expression,
|
||||||
|
};
|
||||||
|
value.clauses[0].mutations = [
|
||||||
|
{
|
||||||
|
kind: 'derive',
|
||||||
|
pool: 'effect',
|
||||||
|
value: { ref: { source: 'reducer' } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const upgradeScale = {
|
||||||
|
op: 'add' as const,
|
||||||
|
left: 1,
|
||||||
|
right: {
|
||||||
|
op: 'divide' as const,
|
||||||
|
left: {
|
||||||
|
ref: {
|
||||||
|
source: 'equipment' as const,
|
||||||
|
path: 'protocolTalentUpgrades',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
right: 5,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
value.clauses[0].outcomes[0].variants = {
|
||||||
|
I: {
|
||||||
|
value: 7.25,
|
||||||
|
expression: {
|
||||||
|
op: 'multiply',
|
||||||
|
left: { ref: { source: 'variant_value' } },
|
||||||
|
right: upgradeScale,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
II: {
|
||||||
|
value: 9.5,
|
||||||
|
expression: {
|
||||||
|
op: 'multiply',
|
||||||
|
left: { ref: { source: 'variant_value' } },
|
||||||
|
right: upgradeScale,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseConditionalMechanicDraft(value, {
|
||||||
|
variantKeys: ['I', 'II'],
|
||||||
|
allowedAttributeIds: new Set(['weapon-damage']),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
expect(result.value.draft.clauses[0]).toMatchObject({
|
||||||
|
event: 'proximity_snapshot',
|
||||||
|
reducer: { kind: 'derived_scalar', value: expression },
|
||||||
|
outcomes: [
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
I: {
|
||||||
|
value: 7.25,
|
||||||
|
expression: {
|
||||||
|
left: { ref: { source: 'variant_value' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result.value.programs.I.clauses[0].outcomes?.[0].value).toEqual({
|
||||||
|
op: 'multiply',
|
||||||
|
left: 7.25,
|
||||||
|
right: upgradeScale,
|
||||||
|
});
|
||||||
|
expect(result.value.programs.II.clauses[0].outcomes?.[0].value).toEqual({
|
||||||
|
op: 'multiply',
|
||||||
|
left: 9.5,
|
||||||
|
right: upgradeScale,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('exposes assigned-ability activity for kill and assist event conditions', () => {
|
it('exposes assigned-ability activity for kill and assist event conditions', () => {
|
||||||
expect(registeredEventFields('enemy_killed')).toEqual(
|
expect(registeredEventFields('enemy_killed')).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
|
|||||||
@@ -39,9 +39,28 @@ export type ConditionalMechanicPoolDraft = PoolDefinition & {
|
|||||||
expiresAfterSecondsVariants?: Record<string, number>;
|
expiresAfterSecondsVariants?: Record<string, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ConditionalMechanicValueReference =
|
||||||
|
ValueReference | { source: 'variant_value' };
|
||||||
|
|
||||||
|
export type ConditionalNumericExpression =
|
||||||
|
| number
|
||||||
|
| { ref: ConditionalMechanicValueReference }
|
||||||
|
| { op: 'floor'; value: ConditionalNumericExpression }
|
||||||
|
| {
|
||||||
|
op: 'add' | 'subtract' | 'multiply' | 'divide' | 'min' | 'max';
|
||||||
|
left: ConditionalNumericExpression;
|
||||||
|
right: ConditionalNumericExpression;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
op: 'clamp';
|
||||||
|
value: ConditionalNumericExpression;
|
||||||
|
min: ConditionalNumericExpression;
|
||||||
|
max: ConditionalNumericExpression;
|
||||||
|
};
|
||||||
|
|
||||||
export type ConditionalMechanicVariantValue = {
|
export type ConditionalMechanicVariantValue = {
|
||||||
value: number;
|
value: number;
|
||||||
expression?: NumericExpression;
|
expression?: ConditionalNumericExpression;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConditionalMechanicOutcomeDraft = Omit<
|
export type ConditionalMechanicOutcomeDraft = Omit<
|
||||||
@@ -200,6 +219,21 @@ export function registeredEventFields(event: MechanicEventKind) {
|
|||||||
return MECHANIC_EVENT_REGISTRY[event].fields ?? [];
|
return MECHANIC_EVENT_REGISTRY[event].fields ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function eventFieldNumericExpression(path: string): NumericExpression {
|
||||||
|
return { ref: { source: 'event', path } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readEventFieldNumericExpression(
|
||||||
|
expression: NumericExpression,
|
||||||
|
): string | null {
|
||||||
|
return typeof expression === 'object' &&
|
||||||
|
'ref' in expression &&
|
||||||
|
expression.ref.source === 'event' &&
|
||||||
|
expression.ref.path.trim()
|
||||||
|
? expression.ref.path
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
export type MechanicTargetingMode =
|
export type MechanicTargetingMode =
|
||||||
| 'none'
|
| 'none'
|
||||||
| 'allies_within_radius'
|
| 'allies_within_radius'
|
||||||
@@ -452,6 +486,14 @@ function isReference(value: unknown): value is ValueReference {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isConditionalReference(
|
||||||
|
value: unknown,
|
||||||
|
): value is ConditionalMechanicValueReference {
|
||||||
|
return (
|
||||||
|
(object(value) && value.source === 'variant_value') || isReference(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function validateAttributeReferences(
|
function validateAttributeReferences(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
allowedAttributeIds: ReadonlySet<string> | undefined,
|
allowedAttributeIds: ReadonlySet<string> | undefined,
|
||||||
@@ -511,6 +553,29 @@ function isExpression(value: unknown): value is NumericExpression {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isConditionalExpression(
|
||||||
|
value: unknown,
|
||||||
|
): value is ConditionalNumericExpression {
|
||||||
|
if (finite(value)) return true;
|
||||||
|
if (!object(value)) return false;
|
||||||
|
if ('ref' in value) return isConditionalReference(value.ref);
|
||||||
|
if (value.op === 'floor') return isConditionalExpression(value.value);
|
||||||
|
if (value.op === 'clamp') {
|
||||||
|
return (
|
||||||
|
isConditionalExpression(value.value) &&
|
||||||
|
isConditionalExpression(value.min) &&
|
||||||
|
isConditionalExpression(value.max)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
['add', 'subtract', 'multiply', 'divide', 'min', 'max'].includes(
|
||||||
|
String(value.op),
|
||||||
|
) &&
|
||||||
|
isConditionalExpression(value.left) &&
|
||||||
|
isConditionalExpression(value.right)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isOperand(value: unknown) {
|
function isOperand(value: unknown) {
|
||||||
return scalar(value) || isReference(value);
|
return scalar(value) || isReference(value);
|
||||||
}
|
}
|
||||||
@@ -838,7 +903,7 @@ function parseOutcome(
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
configured.expression !== undefined &&
|
configured.expression !== undefined &&
|
||||||
!isExpression(configured.expression)
|
!isConditionalExpression(configured.expression)
|
||||||
) {
|
) {
|
||||||
errors.push(
|
errors.push(
|
||||||
`${path} variant "${variant}" has an invalid value expression.`,
|
`${path} variant "${variant}" has an invalid value expression.`,
|
||||||
@@ -954,6 +1019,37 @@ function parseClauses(
|
|||||||
return clauses;
|
return clauses;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveVariantExpression(
|
||||||
|
expression: ConditionalNumericExpression,
|
||||||
|
variantValue: number,
|
||||||
|
): NumericExpression {
|
||||||
|
if (typeof expression === 'number') return expression;
|
||||||
|
if ('ref' in expression) {
|
||||||
|
return expression.ref.source === 'variant_value'
|
||||||
|
? variantValue
|
||||||
|
: { ref: expression.ref };
|
||||||
|
}
|
||||||
|
if (expression.op === 'floor') {
|
||||||
|
return {
|
||||||
|
op: 'floor',
|
||||||
|
value: resolveVariantExpression(expression.value, variantValue),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (expression.op === 'clamp') {
|
||||||
|
return {
|
||||||
|
op: 'clamp',
|
||||||
|
value: resolveVariantExpression(expression.value, variantValue),
|
||||||
|
min: resolveVariantExpression(expression.min, variantValue),
|
||||||
|
max: resolveVariantExpression(expression.max, variantValue),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
op: expression.op,
|
||||||
|
left: resolveVariantExpression(expression.left, variantValue),
|
||||||
|
right: resolveVariantExpression(expression.right, variantValue),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function programsForDraft(
|
function programsForDraft(
|
||||||
draft: ConditionalMechanicDraftV1,
|
draft: ConditionalMechanicDraftV1,
|
||||||
options: ParseConditionalMechanicDraftOptions,
|
options: ParseConditionalMechanicDraftOptions,
|
||||||
@@ -1002,7 +1098,13 @@ function programsForDraft(
|
|||||||
}) => ({
|
}) => ({
|
||||||
...outcome,
|
...outcome,
|
||||||
id: outcomeKey,
|
id: outcomeKey,
|
||||||
value: variants[variant].expression ?? variants[variant].value,
|
value:
|
||||||
|
variants[variant].expression === undefined
|
||||||
|
? variants[variant].value
|
||||||
|
: resolveVariantExpression(
|
||||||
|
variants[variant].expression,
|
||||||
|
variants[variant].value,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { evaluateMechanicProgram } from './mechanic.js';
|
||||||
import {
|
import {
|
||||||
adaptConditionalEffectProgram,
|
adaptConditionalEffectProgram,
|
||||||
type ConditionalEffectClauseRow,
|
type ConditionalEffectClauseRow,
|
||||||
@@ -912,4 +913,77 @@ describe('conditional-effect persistence adapter', () => {
|
|||||||
if: condition,
|
if: condition,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
it('preserves a variant-value expression in persisted JSON while adapting it to the selected row value', () => {
|
||||||
|
const sourceExpression = {
|
||||||
|
op: 'multiply',
|
||||||
|
left: { ref: { source: 'variant_value' } },
|
||||||
|
right: {
|
||||||
|
op: 'add',
|
||||||
|
left: 1,
|
||||||
|
right: {
|
||||||
|
op: 'divide',
|
||||||
|
left: {
|
||||||
|
ref: {
|
||||||
|
source: 'equipment',
|
||||||
|
path: 'protocolTalentUpgrades',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
right: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const serializedExpression = JSON.stringify(sourceExpression);
|
||||||
|
const persistedOutcome = outcome({
|
||||||
|
state_pool_id: null,
|
||||||
|
value: 7.25,
|
||||||
|
value_expression_json: serializedExpression,
|
||||||
|
value_scaling: 'flat',
|
||||||
|
application_phase: 'on_event',
|
||||||
|
});
|
||||||
|
const result = adaptConditionalEffectProgram(
|
||||||
|
{
|
||||||
|
rule: rule(),
|
||||||
|
statePools: [],
|
||||||
|
clauses: [
|
||||||
|
clause({
|
||||||
|
trigger_id: null,
|
||||||
|
event_kind: 'tick',
|
||||||
|
parameters_json: '{}',
|
||||||
|
state_pool_id: null,
|
||||||
|
mutations_json: '[]',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
outcomes: [persistedOutcome],
|
||||||
|
triggers: [],
|
||||||
|
},
|
||||||
|
{ variantKey: 'I' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 'READY', errors: [] });
|
||||||
|
expect(persistedOutcome.value).toBe(7.25);
|
||||||
|
expect(persistedOutcome.value_expression_json).toBe(serializedExpression);
|
||||||
|
expect(JSON.parse(String(persistedOutcome.value_expression_json))).toEqual(
|
||||||
|
sourceExpression,
|
||||||
|
);
|
||||||
|
expect(result.program?.clauses[0].outcomes?.[0].value).toEqual({
|
||||||
|
op: 'multiply',
|
||||||
|
left: 7.25,
|
||||||
|
right: sourceExpression.right,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.program) return;
|
||||||
|
const runtime = evaluateMechanicProgram(
|
||||||
|
result.program,
|
||||||
|
[{ kind: 'tick', at: 0 }],
|
||||||
|
{ mode: 'custom', encounterSeconds: 1 },
|
||||||
|
undefined,
|
||||||
|
{ protocolTalentUpgrades: 5 },
|
||||||
|
);
|
||||||
|
expect(runtime.status).toBe('OK');
|
||||||
|
expect(runtime.outcomes[0]).toMatchObject({
|
||||||
|
outcomeId: 'outcome-1',
|
||||||
|
value: 14.5,
|
||||||
|
resolvedValue: 14.5,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type Predicate,
|
type Predicate,
|
||||||
type Reducer,
|
type Reducer,
|
||||||
type ScenarioInputDefinition,
|
type ScenarioInputDefinition,
|
||||||
|
type ValueReference,
|
||||||
} from './mechanic.js';
|
} from './mechanic.js';
|
||||||
|
|
||||||
type DatabaseValue = string | number | bigint | boolean | null | undefined;
|
type DatabaseValue = string | number | bigint | boolean | null | undefined;
|
||||||
@@ -257,6 +258,55 @@ function isNumericExpression(value: unknown): value is NumericExpression {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveVariantExpression(
|
||||||
|
value: unknown,
|
||||||
|
variantValue: number,
|
||||||
|
): NumericExpression | null {
|
||||||
|
if (finite(value)) return value;
|
||||||
|
if (!object(value)) return null;
|
||||||
|
if ('ref' in value) {
|
||||||
|
if (
|
||||||
|
object(value.ref) &&
|
||||||
|
value.ref.source === 'variant_value' &&
|
||||||
|
Object.keys(value.ref).length === 1
|
||||||
|
) {
|
||||||
|
return variantValue;
|
||||||
|
}
|
||||||
|
return isValueReference(value.ref)
|
||||||
|
? { ref: value.ref as ValueReference }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (value.op === 'floor') {
|
||||||
|
const nested = resolveVariantExpression(value.value, variantValue);
|
||||||
|
return nested === null ? null : { op: 'floor', value: nested };
|
||||||
|
}
|
||||||
|
if (value.op === 'clamp') {
|
||||||
|
const nested = resolveVariantExpression(value.value, variantValue);
|
||||||
|
const min = resolveVariantExpression(value.min, variantValue);
|
||||||
|
const max = resolveVariantExpression(value.max, variantValue);
|
||||||
|
return nested === null || min === null || max === null
|
||||||
|
? null
|
||||||
|
: { op: 'clamp', value: nested, min, max };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!['add', 'subtract', 'multiply', 'divide', 'min', 'max'].includes(
|
||||||
|
String(value.op),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const left = resolveVariantExpression(value.left, variantValue);
|
||||||
|
const right = resolveVariantExpression(value.right, variantValue);
|
||||||
|
return left === null || right === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
op: value.op as
|
||||||
|
'add' | 'subtract' | 'multiply' | 'divide' | 'min' | 'max',
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function isPredicate(value: unknown): value is Predicate {
|
function isPredicate(value: unknown): value is Predicate {
|
||||||
if (!object(value) || typeof value.kind !== 'string') return false;
|
if (!object(value) || typeof value.kind !== 'string') return false;
|
||||||
if (value.kind === 'true') return true;
|
if (value.kind === 'true') return true;
|
||||||
@@ -673,11 +723,8 @@ function parseOutcome(
|
|||||||
) {
|
) {
|
||||||
errors.push(`${path} has an unsupported application phase.`);
|
errors.push(`${path} has an unsupported application phase.`);
|
||||||
}
|
}
|
||||||
let value: NumericExpression = requiredNumber(
|
const variantValue = requiredNumber(row.value, `${path} value`, errors);
|
||||||
row.value,
|
let value: NumericExpression = variantValue;
|
||||||
`${path} value`,
|
|
||||||
errors,
|
|
||||||
);
|
|
||||||
if (
|
if (
|
||||||
row.value_expression_json !== null &&
|
row.value_expression_json !== null &&
|
||||||
row.value_expression_json !== undefined &&
|
row.value_expression_json !== undefined &&
|
||||||
@@ -689,7 +736,8 @@ function parseOutcome(
|
|||||||
errors,
|
errors,
|
||||||
);
|
);
|
||||||
if (parsed.ok) {
|
if (parsed.ok) {
|
||||||
if (isNumericExpression(parsed.value)) value = parsed.value;
|
const resolved = resolveVariantExpression(parsed.value, variantValue);
|
||||||
|
if (resolved !== null) value = resolved;
|
||||||
else
|
else
|
||||||
errors.push(
|
errors.push(
|
||||||
`${path} value expression is not a valid numeric expression.`,
|
`${path} value expression is not a valid numeric expression.`,
|
||||||
|
|||||||
@@ -19,6 +19,84 @@ const custom = (
|
|||||||
inputs,
|
inputs,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const closeAndPersonalProgram = (
|
||||||
|
resolvedVariantValue: number,
|
||||||
|
): MechanicProgram => ({
|
||||||
|
id: 'close-and-personal-engineering',
|
||||||
|
name: 'Close and Personal Engineering',
|
||||||
|
pools: [
|
||||||
|
{
|
||||||
|
key: 'eligible-allies',
|
||||||
|
label: 'Eligible Allies',
|
||||||
|
scope: 'agent',
|
||||||
|
initial: 0,
|
||||||
|
min: 0,
|
||||||
|
max: 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
clauses: [
|
||||||
|
{
|
||||||
|
id: 'sample-eligible-allies',
|
||||||
|
when: 'proximity_snapshot',
|
||||||
|
reducer: {
|
||||||
|
kind: 'derived_scalar',
|
||||||
|
value: {
|
||||||
|
ref: {
|
||||||
|
source: 'event',
|
||||||
|
path: 'values.alliesWithin30MetersWithAgentGrantedBuff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mutations: [
|
||||||
|
{
|
||||||
|
kind: 'derive',
|
||||||
|
pool: 'eligible-allies',
|
||||||
|
value: { ref: { source: 'reducer' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'resolve-engineering',
|
||||||
|
when: 'tick',
|
||||||
|
reducer: { kind: 'none' },
|
||||||
|
mutations: [{ kind: 'none' }],
|
||||||
|
outcomes: [
|
||||||
|
{
|
||||||
|
id: 'engineering',
|
||||||
|
label: 'Engineering',
|
||||||
|
phase: 'on_event',
|
||||||
|
kind: 'attribute_modifier',
|
||||||
|
scope: 'self',
|
||||||
|
basis: 'flat',
|
||||||
|
attributeId: 'engineering',
|
||||||
|
value: {
|
||||||
|
op: 'multiply',
|
||||||
|
left: {
|
||||||
|
op: 'multiply',
|
||||||
|
left: { ref: { source: 'pool', pool: 'eligible-allies' } },
|
||||||
|
right: resolvedVariantValue,
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
op: 'add',
|
||||||
|
left: 1,
|
||||||
|
right: {
|
||||||
|
op: 'divide',
|
||||||
|
left: {
|
||||||
|
ref: {
|
||||||
|
source: 'equipment',
|
||||||
|
path: 'protocolTalentUpgrades',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
right: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
describe('deterministic mechanic probabilities and assigned abilities', () => {
|
describe('deterministic mechanic probabilities and assigned abilities', () => {
|
||||||
it('rolls outcome probability independently with a reproducible seed and preserves a refreshed window', () => {
|
it('rolls outcome probability independently with a reproducible seed and preserves a refreshed window', () => {
|
||||||
const program: MechanicProgram = {
|
const program: MechanicProgram = {
|
||||||
@@ -321,6 +399,23 @@ describe('structured mechanic runtime', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('advertises the optional non-negative integer proximity count without making snapshots strict', () => {
|
||||||
|
const definition = MECHANIC_EVENT_REGISTRY.proximity_snapshot;
|
||||||
|
const countField = definition.fields?.find(
|
||||||
|
({ path }) => path === 'values.alliesWithin30MetersWithAgentGrantedBuff',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(definition.strictFields).not.toBe(true);
|
||||||
|
expect(countField).toEqual({
|
||||||
|
path: 'values.alliesWithin30MetersWithAgentGrantedBuff',
|
||||||
|
label: 'Allies within 30 meters with an Agent-granted buff',
|
||||||
|
type: 'number',
|
||||||
|
minimum: 0,
|
||||||
|
integer: true,
|
||||||
|
});
|
||||||
|
expect(countField?.required).not.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('advertises imported support and equipment-use trigger events', () => {
|
it('advertises imported support and equipment-use trigger events', () => {
|
||||||
expect(MECHANIC_EVENT_REGISTRY.ally_downed).toMatchObject({
|
expect(MECHANIC_EVENT_REGISTRY.ally_downed).toMatchObject({
|
||||||
key: 'ally_downed',
|
key: 'ally_downed',
|
||||||
@@ -1306,6 +1401,7 @@ describe('structured mechanic runtime', () => {
|
|||||||
],
|
],
|
||||||
custom(),
|
custom(),
|
||||||
);
|
);
|
||||||
|
expect(result.status).toBe('OK');
|
||||||
expect(result.finalState[0].value).toBe(2);
|
expect(result.finalState[0].value).toBe(2);
|
||||||
const maximum = evaluateMechanicProgram(program, [], {
|
const maximum = evaluateMechanicProgram(program, [], {
|
||||||
mode: 'maximum',
|
mode: 'maximum',
|
||||||
@@ -1314,6 +1410,81 @@ describe('structured mechanic runtime', () => {
|
|||||||
expect(maximum.finalState[0].value).toBe(3);
|
expect(maximum.finalState[0].value).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ count: 0, upgrades: 0, multiplier: 0 },
|
||||||
|
{ count: 3, upgrades: 0, multiplier: 3 },
|
||||||
|
{ count: 3, upgrades: 5, multiplier: 6 },
|
||||||
|
])(
|
||||||
|
'resolves Close-and-Personal-style Engineering for count $count and $upgrades OS upgrades',
|
||||||
|
({ count, upgrades, multiplier }) => {
|
||||||
|
const resolvedVariantValue = 7.25;
|
||||||
|
const result = evaluateMechanicProgram(
|
||||||
|
closeAndPersonalProgram(resolvedVariantValue),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kind: 'proximity_snapshot',
|
||||||
|
at: 0,
|
||||||
|
values: {
|
||||||
|
alliesWithin30MetersWithAgentGrantedBuff: count,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ kind: 'tick', at: 1 },
|
||||||
|
],
|
||||||
|
custom(),
|
||||||
|
undefined,
|
||||||
|
{ protocolTalentUpgrades: upgrades },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe('OK');
|
||||||
|
expect(result.finalState).toContainEqual(
|
||||||
|
expect.objectContaining({ pool: 'eligible-allies', value: count }),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
result.outcomes.find(({ outcomeId }) => outcomeId === 'engineering'),
|
||||||
|
).toMatchObject({
|
||||||
|
value: resolvedVariantValue * multiplier,
|
||||||
|
resolvedValue: resolvedVariantValue * multiplier,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
count: '2',
|
||||||
|
error:
|
||||||
|
'Allies within 30 meters with an Agent-granted buff must be a number.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
count: -1,
|
||||||
|
error:
|
||||||
|
'Allies within 30 meters with an Agent-granted buff must be 0 or greater.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
count: 1.5,
|
||||||
|
error:
|
||||||
|
'Allies within 30 meters with an Agent-granted buff must be an integer.',
|
||||||
|
},
|
||||||
|
])('rejects invalid proximity ally count $count', ({ count, error }) => {
|
||||||
|
const result = evaluateMechanicProgram(
|
||||||
|
closeAndPersonalProgram(7.25),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kind: 'proximity_snapshot',
|
||||||
|
at: 0,
|
||||||
|
values: {
|
||||||
|
alliesWithin30MetersWithAgentGrantedBuff: count,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
custom(),
|
||||||
|
undefined,
|
||||||
|
{ protocolTalentUpgrades: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe('INVALID');
|
||||||
|
expect(result.errors).toContain(`Event 1 (Proximity snapshot) ${error}`);
|
||||||
|
});
|
||||||
|
|
||||||
it('shares one movement charge pool across generation and consumption clauses', () => {
|
it('shares one movement charge pool across generation and consumption clauses', () => {
|
||||||
const program: MechanicProgram = {
|
const program: MechanicProgram = {
|
||||||
id: 'mobile-charge',
|
id: 'mobile-charge',
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ export const MECHANIC_EVENT_KINDS = [
|
|||||||
'tick',
|
'tick',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export const PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_VALUE_KEY =
|
||||||
|
'alliesWithin30MetersWithAgentGrantedBuff' as const;
|
||||||
|
export const PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_FIELD =
|
||||||
|
`values.${PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_VALUE_KEY}` as const;
|
||||||
|
export const PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_INPUT =
|
||||||
|
'allies-within-30m-with-agent-granted-buff' as const;
|
||||||
|
|
||||||
export type MechanicEventKind = (typeof MECHANIC_EVENT_KINDS)[number];
|
export type MechanicEventKind = (typeof MECHANIC_EVENT_KINDS)[number];
|
||||||
export type MechanicEventDefinition = {
|
export type MechanicEventDefinition = {
|
||||||
key: MechanicEventKind;
|
key: MechanicEventKind;
|
||||||
@@ -39,6 +46,7 @@ export type MechanicEventDefinition = {
|
|||||||
type: 'boolean' | 'number' | 'string';
|
type: 'boolean' | 'number' | 'string';
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
minimum?: number;
|
minimum?: number;
|
||||||
|
integer?: boolean;
|
||||||
}[];
|
}[];
|
||||||
strictFields?: boolean;
|
strictFields?: boolean;
|
||||||
};
|
};
|
||||||
@@ -308,6 +316,15 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<
|
|||||||
label: 'Proximity snapshot',
|
label: 'Proximity snapshot',
|
||||||
description:
|
description:
|
||||||
'A sampled count or state for nearby allies, enemies, and Agent-owned buffs.',
|
'A sampled count or state for nearby allies, enemies, and Agent-owned buffs.',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
path: PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_FIELD,
|
||||||
|
label: 'Allies within 30 meters with an Agent-granted buff',
|
||||||
|
type: 'number',
|
||||||
|
minimum: 0,
|
||||||
|
integer: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
firing_started: {
|
firing_started: {
|
||||||
key: 'firing_started',
|
key: 'firing_started',
|
||||||
@@ -2049,6 +2066,13 @@ function validateMechanicEvents(events: MechanicEvent[]) {
|
|||||||
) {
|
) {
|
||||||
errors.push(`${fieldPath} must be ${field.minimum} or greater.`);
|
errors.push(`${fieldPath} must be ${field.minimum} or greater.`);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
field.type === 'number' &&
|
||||||
|
field.integer &&
|
||||||
|
!Number.isInteger(value)
|
||||||
|
) {
|
||||||
|
errors.push(`${fieldPath} must be an integer.`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const hasExtraHealth = getPath(event, 'values.hasExtraHealth');
|
const hasExtraHealth = getPath(event, 'values.hasExtraHealth');
|
||||||
const currentExtraHealth = getPath(event, 'values.currentExtraHealth');
|
const currentExtraHealth = getPath(event, 'values.currentExtraHealth');
|
||||||
|
|||||||
@@ -9,15 +9,18 @@
|
|||||||
blankConditionalMechanicDraft,
|
blankConditionalMechanicDraft,
|
||||||
currentHealthPercentBandPredicate,
|
currentHealthPercentBandPredicate,
|
||||||
eventFieldComparisonPredicate,
|
eventFieldComparisonPredicate,
|
||||||
|
eventFieldNumericExpression,
|
||||||
mechanicTargetingForMode,
|
mechanicTargetingForMode,
|
||||||
mechanicTargetingMode,
|
mechanicTargetingMode,
|
||||||
parseConditionalMechanicDraft,
|
parseConditionalMechanicDraft,
|
||||||
readCurrentHealthPercentBand,
|
readCurrentHealthPercentBand,
|
||||||
readEventFieldComparison,
|
readEventFieldComparison,
|
||||||
|
readEventFieldNumericExpression,
|
||||||
registeredEventFields,
|
registeredEventFields,
|
||||||
readStatePoolThreshold,
|
readStatePoolThreshold,
|
||||||
statePoolThresholdPredicate,
|
statePoolThresholdPredicate,
|
||||||
type ConditionalMechanicClauseDraft,
|
type ConditionalMechanicClauseDraft,
|
||||||
|
type ConditionalNumericExpression,
|
||||||
type ConditionalMechanicDraftV1,
|
type ConditionalMechanicDraftV1,
|
||||||
type ConditionalMechanicOutcomeDraft,
|
type ConditionalMechanicOutcomeDraft,
|
||||||
type ConditionalMechanicPoolDraft,
|
type ConditionalMechanicPoolDraft,
|
||||||
@@ -31,6 +34,7 @@
|
|||||||
formatMechanicProgram,
|
formatMechanicProgram,
|
||||||
MECHANIC_EVENT_KINDS,
|
MECHANIC_EVENT_KINDS,
|
||||||
MECHANIC_EVENT_REGISTRY,
|
MECHANIC_EVENT_REGISTRY,
|
||||||
|
PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_FIELD,
|
||||||
type ClauseMutation,
|
type ClauseMutation,
|
||||||
type MechanicOutcomeScope,
|
type MechanicOutcomeScope,
|
||||||
type NumericExpression,
|
type NumericExpression,
|
||||||
@@ -408,6 +412,40 @@
|
|||||||
updateClause(index, { reducer });
|
updateClause(index, { reducer });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function derivedScalarModel(clause: ConditionalMechanicClauseDraft) {
|
||||||
|
if (clause.reducer.kind !== 'derived_scalar') return 'static';
|
||||||
|
if (typeof clause.reducer.value === 'number') return 'static';
|
||||||
|
return readEventFieldNumericExpression(clause.reducer.value) ? 'event' : 'advanced';
|
||||||
|
}
|
||||||
|
|
||||||
|
function numericEventFields(clause: ConditionalMechanicClauseDraft) {
|
||||||
|
return registeredEventFields(clause.event).filter((field) => field.type === 'number');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDerivedScalarModel(clauseIndex: number, model: string) {
|
||||||
|
const clause = rule.clauses[clauseIndex];
|
||||||
|
if (clause.reducer.kind !== 'derived_scalar') return;
|
||||||
|
if (model === 'static') {
|
||||||
|
updateClause(clauseIndex, { reducer: { kind: 'derived_scalar', value: 1 } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const field = numericEventFields(clause)[0];
|
||||||
|
if (model === 'event' && field) {
|
||||||
|
updateClause(clauseIndex, {
|
||||||
|
reducer: {
|
||||||
|
kind: 'derived_scalar',
|
||||||
|
value: eventFieldNumericExpression(field.path)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDerivedScalarEventField(clauseIndex: number, path: string) {
|
||||||
|
updateClause(clauseIndex, {
|
||||||
|
reducer: { kind: 'derived_scalar', value: eventFieldNumericExpression(path) }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function updateOutcome(clauseIndex: number, outcomeIndex: number, patch: Partial<ConditionalMechanicOutcomeDraft>) {
|
function updateOutcome(clauseIndex: number, outcomeIndex: number, patch: Partial<ConditionalMechanicOutcomeDraft>) {
|
||||||
const outcomes = [...rule.clauses[clauseIndex].outcomes];
|
const outcomes = [...rule.clauses[clauseIndex].outcomes];
|
||||||
outcomes[outcomeIndex] = { ...outcomes[outcomeIndex], ...patch };
|
outcomes[outcomeIndex] = { ...outcomes[outcomeIndex], ...patch };
|
||||||
@@ -517,6 +555,10 @@
|
|||||||
const eventValueOptions = [
|
const eventValueOptions = [
|
||||||
{ value: 'values.enemyCount', label: 'ENEMY COUNT' },
|
{ value: 'values.enemyCount', label: 'ENEMY COUNT' },
|
||||||
{ value: 'values.allyCount', label: 'ALLY COUNT' },
|
{ value: 'values.allyCount', label: 'ALLY COUNT' },
|
||||||
|
{
|
||||||
|
value: PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_FIELD,
|
||||||
|
label: 'AGENT-BUFFED ALLIES ≤30M'
|
||||||
|
},
|
||||||
{ value: 'values.magazineRemaining', label: 'ROUNDS LEFT' },
|
{ value: 'values.magazineRemaining', label: 'ROUNDS LEFT' },
|
||||||
{ value: 'values.magazinePercent', label: 'MAGAZINE %' },
|
{ value: 'values.magazinePercent', label: 'MAGAZINE %' },
|
||||||
{ value: 'values.distanceMeters', label: 'DISTANCE' }
|
{ value: 'values.distanceMeters', label: 'DISTANCE' }
|
||||||
@@ -534,10 +576,10 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function protocolUpgradeExpression(value: number): NumericExpression {
|
function protocolUpgradeExpression(): ConditionalNumericExpression {
|
||||||
return {
|
return {
|
||||||
op: 'multiply',
|
op: 'multiply',
|
||||||
left: value,
|
left: { ref: { source: 'variant_value' } },
|
||||||
right: {
|
right: {
|
||||||
op: 'add',
|
op: 'add',
|
||||||
left: 1,
|
left: 1,
|
||||||
@@ -578,17 +620,17 @@
|
|||||||
typeof expression === 'object'
|
typeof expression === 'object'
|
||||||
&& 'op' in expression
|
&& 'op' in expression
|
||||||
&& expression.op === 'multiply'
|
&& expression.op === 'multiply'
|
||||||
&& typeof expression.left === 'number'
|
|
||||||
&& typeof expression.right === 'object'
|
&& typeof expression.right === 'object'
|
||||||
) {
|
) {
|
||||||
if ('ref' in expression.right) {
|
if (typeof expression.left === 'number' && 'ref' in expression.right) {
|
||||||
if (expression.right.ref.source === 'event') return 'event';
|
if (expression.right.ref.source === 'event') return 'event';
|
||||||
if (expression.right.ref.source === 'scenario') return 'scenario';
|
if (expression.right.ref.source === 'scenario') return 'scenario';
|
||||||
if (expression.right.ref.source === 'reducer') return 'reducer';
|
if (expression.right.ref.source === 'reducer') return 'reducer';
|
||||||
if (expression.right.ref.source === 'attribute') return 'attribute_value';
|
if (expression.right.ref.source === 'attribute') return 'attribute_value';
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
'op' in expression.right
|
typeof expression.left === 'number'
|
||||||
|
&& 'op' in expression.right
|
||||||
&& expression.right.op === 'divide'
|
&& expression.right.op === 'divide'
|
||||||
&& typeof expression.right.left === 'object'
|
&& typeof expression.right.left === 'object'
|
||||||
&& 'ref' in expression.right.left
|
&& 'ref' in expression.right.left
|
||||||
@@ -596,7 +638,13 @@
|
|||||||
&& expression.right.right === 100
|
&& expression.right.right === 100
|
||||||
) return 'attribute';
|
) return 'attribute';
|
||||||
if (
|
if (
|
||||||
'op' in expression.right
|
(typeof expression.left === 'number'
|
||||||
|
|| (
|
||||||
|
typeof expression.left === 'object'
|
||||||
|
&& 'ref' in expression.left
|
||||||
|
&& expression.left.ref.source === 'variant_value'
|
||||||
|
))
|
||||||
|
&& 'op' in expression.right
|
||||||
&& expression.right.op === 'add'
|
&& expression.right.op === 'add'
|
||||||
&& typeof expression.right.right === 'object'
|
&& typeof expression.right.right === 'object'
|
||||||
&& 'op' in expression.right.right
|
&& 'op' in expression.right.right
|
||||||
@@ -657,7 +705,7 @@
|
|||||||
model: OutcomeValueModel,
|
model: OutcomeValueModel,
|
||||||
value: number,
|
value: number,
|
||||||
reference = ''
|
reference = ''
|
||||||
): NumericExpression | undefined {
|
): ConditionalNumericExpression | undefined {
|
||||||
if (model === 'event') return referenceExpression('event', reference || 'values.enemyCount', value);
|
if (model === 'event') return referenceExpression('event', reference || 'values.enemyCount', value);
|
||||||
if (model === 'attribute') return reference
|
if (model === 'attribute') return reference
|
||||||
? attributePercentExpression(value, reference)
|
? attributePercentExpression(value, reference)
|
||||||
@@ -671,7 +719,7 @@
|
|||||||
if (model === 'reducer') {
|
if (model === 'reducer') {
|
||||||
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
|
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
|
||||||
}
|
}
|
||||||
if (model === 'protocol_upgrades') return protocolUpgradeExpression(value);
|
if (model === 'protocol_upgrades') return protocolUpgradeExpression();
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,7 +746,7 @@
|
|||||||
updateOutcome(clauseIndex, outcomeIndex, { variants });
|
updateOutcome(clauseIndex, outcomeIndex, { variants });
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceExpressionBase(expression: NumericExpression, value: number): NumericExpression {
|
function replaceExpressionBase(expression: ConditionalNumericExpression, value: number): ConditionalNumericExpression {
|
||||||
const model = outcomeValueModel({
|
const model = outcomeValueModel({
|
||||||
key: 'temporary',
|
key: 'temporary',
|
||||||
label: 'Temporary',
|
label: 'Temporary',
|
||||||
@@ -743,7 +791,7 @@
|
|||||||
if (model === 'reducer') {
|
if (model === 'reducer') {
|
||||||
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
|
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
|
||||||
}
|
}
|
||||||
if (model === 'protocol_upgrades') return protocolUpgradeExpression(value);
|
if (model === 'protocol_upgrades') return protocolUpgradeExpression();
|
||||||
return expression;
|
return expression;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -802,7 +850,7 @@
|
|||||||
updateClause(clauseIndex, { mutations });
|
updateClause(clauseIndex, { mutations });
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMutation(clauseIndex: number, mutationIndex: number, kind: 'add' | 'set' | 'consume' | 'clear', pool?: string, value?: number) {
|
function updateMutation(clauseIndex: number, mutationIndex: number, kind: 'add' | 'set' | 'consume' | 'derive' | 'clear', pool?: string, value?: number) {
|
||||||
const mutations = [...rule.clauses[clauseIndex].mutations];
|
const mutations = [...rule.clauses[clauseIndex].mutations];
|
||||||
const current = mutations[mutationIndex];
|
const current = mutations[mutationIndex];
|
||||||
const nextPool = pool ?? ('pool' in current ? current.pool : rule.pools[0]?.key ?? '');
|
const nextPool = pool ?? ('pool' in current ? current.pool : rule.pools[0]?.key ?? '');
|
||||||
@@ -812,7 +860,9 @@
|
|||||||
: undefined;
|
: undefined;
|
||||||
mutations[mutationIndex] = kind === 'clear'
|
mutations[mutationIndex] = kind === 'clear'
|
||||||
? { kind, pool: nextPool, entity }
|
? { kind, pool: nextPool, entity }
|
||||||
: { kind, pool: nextPool, value: value ?? ('value' in current && typeof current.value === 'number' ? current.value : 1), entity };
|
: kind === 'derive'
|
||||||
|
? { kind, pool: nextPool, value: { ref: { source: 'reducer' } }, entity }
|
||||||
|
: { kind, pool: nextPool, value: value ?? ('value' in current && typeof current.value === 'number' ? current.value : 1), entity };
|
||||||
updateClause(clauseIndex, { mutations });
|
updateClause(clauseIndex, { mutations });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1134,7 +1184,29 @@
|
|||||||
{:else if clause.reducer.kind === 'periodic'}
|
{:else if clause.reducer.kind === 'periodic'}
|
||||||
<label class="field">INTERVAL · SECONDS<input type="number" min="0.01" step="any" value={typeof clause.reducer.intervalSeconds === 'number' ? clause.reducer.intervalSeconds : 1} oninput={(event) => setReducerNumber(clauseIndex,'intervalSeconds',event.currentTarget.value)}/></label>
|
<label class="field">INTERVAL · SECONDS<input type="number" min="0.01" step="any" value={typeof clause.reducer.intervalSeconds === 'number' ? clause.reducer.intervalSeconds : 1} oninput={(event) => setReducerNumber(clauseIndex,'intervalSeconds',event.currentTarget.value)}/></label>
|
||||||
{:else if clause.reducer.kind === 'derived_scalar'}
|
{:else if clause.reducer.kind === 'derived_scalar'}
|
||||||
<label class="field">DERIVED VALUE<input type="number" step="any" value={typeof clause.reducer.value === 'number' ? clause.reducer.value : 1} oninput={(event) => setReducerNumber(clauseIndex,'value',event.currentTarget.value)}/></label>
|
<SegmentedChoice
|
||||||
|
legend="DERIVED VALUE SOURCE"
|
||||||
|
name={`${idPrefix}-derived-source-${clauseIndex}`}
|
||||||
|
options={[
|
||||||
|
{value:'static',label:'STATIC'},
|
||||||
|
{value:'event',label:'EVENT FIELD',disabled:numericEventFields(clause).length === 0},
|
||||||
|
...(derivedScalarModel(clause) === 'advanced' ? [{value:'advanced',label:'ADVANCED',disabled:true}] : [])
|
||||||
|
]}
|
||||||
|
value={derivedScalarModel(clause)}
|
||||||
|
onchange={(values) => setDerivedScalarModel(clauseIndex,values[0])}
|
||||||
|
/>
|
||||||
|
{#if derivedScalarModel(clause) === 'event'}
|
||||||
|
<SegmentedChoice
|
||||||
|
legend="NUMERIC EVENT FIELD"
|
||||||
|
name={`${idPrefix}-derived-event-${clauseIndex}`}
|
||||||
|
options={numericEventFields(clause).map((field) => ({value:field.path,label:field.label.toUpperCase()}))}
|
||||||
|
value={readEventFieldNumericExpression(clause.reducer.value) ?? ''}
|
||||||
|
columns={Math.min(3,numericEventFields(clause).length)}
|
||||||
|
onchange={(values) => setDerivedScalarEventField(clauseIndex,values[0])}
|
||||||
|
/>
|
||||||
|
{:else if derivedScalarModel(clause) === 'static'}
|
||||||
|
<label class="field">DERIVED VALUE<input type="number" step="any" value={typeof clause.reducer.value === 'number' ? clause.reducer.value : 1} oninput={(event) => setReducerNumber(clauseIndex,'value',event.currentTarget.value)}/></label>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1159,9 +1231,9 @@
|
|||||||
<SegmentedChoice
|
<SegmentedChoice
|
||||||
legend="OPERATION"
|
legend="OPERATION"
|
||||||
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
|
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
|
||||||
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'clear',label:'CLEAR'}]}
|
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'derive',label:'DERIVED VALUE'},{value:'clear',label:'CLEAR'}]}
|
||||||
value={mutation.kind === 'none' || mutation.kind === 'derive' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
|
value={mutation.kind === 'none' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
|
||||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'clear')}
|
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'derive'|'clear')}
|
||||||
/>
|
/>
|
||||||
<SegmentedChoice
|
<SegmentedChoice
|
||||||
legend="STATE POOL"
|
legend="STATE POOL"
|
||||||
@@ -1169,10 +1241,12 @@
|
|||||||
options={rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))}
|
options={rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))}
|
||||||
value={'pool' in mutation ? mutation.pool : rule.pools[0]?.key ?? ''}
|
value={'pool' in mutation ? mutation.pool : rule.pools[0]?.key ?? ''}
|
||||||
columns={Math.min(4,Math.max(1,rule.pools.length))}
|
columns={Math.min(4,Math.max(1,rule.pools.length))}
|
||||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'clear' ? 'clear' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])}
|
onchange={(values) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'clear' ? 'clear' : mutation.kind === 'derive' ? 'derive' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])}
|
||||||
/>
|
/>
|
||||||
{#if mutation.kind !== 'clear'}
|
{#if mutation.kind !== 'clear' && mutation.kind !== 'derive'}
|
||||||
<label class="field">VALUE<input type="number" step="any" value={'value' in mutation && typeof mutation.value === 'number' ? mutation.value : 1} oninput={(event) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',undefined,Number(event.currentTarget.value))}/></label>
|
<label class="field">VALUE<input type="number" step="any" value={'value' in mutation && typeof mutation.value === 'number' ? mutation.value : 1} oninput={(event) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',undefined,Number(event.currentTarget.value))}/></label>
|
||||||
|
{:else if mutation.kind === 'derive'}
|
||||||
|
<div class="branch-readout"><span>VALUE</span><strong>DERIVED SCALAR</strong></div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if 'pool' in mutation && rule.pools.find((pool) => pool.key === mutation.pool)?.scope === 'target'}
|
{#if 'pool' in mutation && rule.pools.find((pool) => pool.key === mutation.pool)?.scope === 'target'}
|
||||||
<SegmentedChoice
|
<SegmentedChoice
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ describe('AI mechanic authoring prompt', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
|
expect(AI_MECHANIC_PROMPT_VERSION).toBe(11);
|
||||||
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
|
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
|
||||||
expect.arrayContaining(['delivery', 'durationSeconds']),
|
expect.arrayContaining(['delivery', 'durationSeconds']),
|
||||||
);
|
);
|
||||||
@@ -136,7 +136,7 @@ describe('AI mechanic authoring prompt', () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
||||||
'Prompt contract version: 10',
|
'Prompt contract version: 11',
|
||||||
);
|
);
|
||||||
|
|
||||||
const advertised = structuredClone(
|
const advertised = structuredClone(
|
||||||
@@ -185,7 +185,7 @@ describe('AI mechanic authoring prompt', () => {
|
|||||||
expect(parsed.ok).toBe(true);
|
expect(parsed.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('advertises the canonical Extra Health change event contract in version 10', () => {
|
it('advertises the canonical Extra Health change event contract in version 11', () => {
|
||||||
const input = JSON.parse(
|
const input = JSON.parse(
|
||||||
buildAiMechanicAuthoringInput({
|
buildAiMechanicAuthoringInput({
|
||||||
request,
|
request,
|
||||||
@@ -196,9 +196,9 @@ describe('AI mechanic authoring prompt', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
|
expect(AI_MECHANIC_PROMPT_VERSION).toBe(11);
|
||||||
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
||||||
'Prompt contract version: 10',
|
'Prompt contract version: 11',
|
||||||
);
|
);
|
||||||
expect(input.canonicalCatalog.events).toContainEqual({
|
expect(input.canonicalCatalog.events).toContainEqual({
|
||||||
key: 'extra_health_changed',
|
key: 'extra_health_changed',
|
||||||
@@ -325,7 +325,7 @@ describe('AI mechanic authoring prompt', () => {
|
|||||||
expect(parsed.ok).toBe(true);
|
expect(parsed.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('advertises reproducible outcome and clause proc probabilities in version 10', () => {
|
it('advertises reproducible outcome and clause proc probabilities in version 11', () => {
|
||||||
const input = JSON.parse(
|
const input = JSON.parse(
|
||||||
buildAiMechanicAuthoringInput({
|
buildAiMechanicAuthoringInput({
|
||||||
request,
|
request,
|
||||||
@@ -336,7 +336,7 @@ describe('AI mechanic authoring prompt', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
|
expect(AI_MECHANIC_PROMPT_VERSION).toBe(11);
|
||||||
expect(input.canonicalCatalog.grammar.clause.optional).toEqual(
|
expect(input.canonicalCatalog.grammar.clause.optional).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
'procChancePercent',
|
'procChancePercent',
|
||||||
@@ -489,4 +489,62 @@ describe('AI mechanic authoring prompt', () => {
|
|||||||
});
|
});
|
||||||
expect(parsed.ok).toBe(true);
|
expect(parsed.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
it('advertises draft variant-value scaling and the typed proximity input in version 11', () => {
|
||||||
|
const input = JSON.parse(
|
||||||
|
buildAiMechanicAuthoringInput({
|
||||||
|
request: {
|
||||||
|
...request,
|
||||||
|
context: {
|
||||||
|
...request.context,
|
||||||
|
sourceName: 'Close and Personal',
|
||||||
|
sourceDescription:
|
||||||
|
'Increase Weapon Damage by (1)% plus (2)% for each nearby ally with a buff granted by you.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
attributes: [],
|
||||||
|
events: [MECHANIC_EVENT_REGISTRY.proximity_snapshot],
|
||||||
|
actions: [],
|
||||||
|
recipeKeys: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const grammar = input.canonicalCatalog.grammar;
|
||||||
|
const proximityEvent = input.canonicalCatalog.events[0];
|
||||||
|
|
||||||
|
expect(AI_MECHANIC_PROMPT_VERSION).toBe(11);
|
||||||
|
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
||||||
|
'Prompt contract version: 11',
|
||||||
|
);
|
||||||
|
expect(grammar.valueReferences).toContain('variant_value');
|
||||||
|
expect(grammar.draftOutcomeExpressionReferences).toEqual(['variant_value']);
|
||||||
|
expect(grammar.referenceShapes).toContainEqual({
|
||||||
|
ref: { source: 'variant_value' },
|
||||||
|
});
|
||||||
|
expect(proximityEvent.fields).toContainEqual({
|
||||||
|
path: 'values.alliesWithin30MetersWithAgentGrantedBuff',
|
||||||
|
label: 'Allies within 30 meters with an Agent-granted buff',
|
||||||
|
type: 'number',
|
||||||
|
minimum: 0,
|
||||||
|
integer: true,
|
||||||
|
});
|
||||||
|
expect(proximityEvent.strictFields).toBeUndefined();
|
||||||
|
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toEqual(
|
||||||
|
expect.stringContaining('unresolved numbered source placeholder'),
|
||||||
|
);
|
||||||
|
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toEqual(
|
||||||
|
expect.stringContaining('{ref:{source:"variant_value"}}'),
|
||||||
|
);
|
||||||
|
expect(input.examplesAndRules).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.stringContaining('only inside an outcome variant expression'),
|
||||||
|
expect.stringContaining('outcome variants[variantKey].value'),
|
||||||
|
expect.stringContaining(
|
||||||
|
'{ref:{source:"event",path:"values.alliesWithin30MetersWithAgentGrantedBuff"}}',
|
||||||
|
),
|
||||||
|
expect.stringContaining(
|
||||||
|
'{key:"allies-within-30m-with-agent-granted-buff"',
|
||||||
|
),
|
||||||
|
expect.stringContaining('readiness:"ready",min:0'),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES } from '$lib/calculations/mec
|
|||||||
import { MECHANIC_COMBAT_REFERENCE_REGISTRY } from '$lib/calculations/mechanic';
|
import { MECHANIC_COMBAT_REFERENCE_REGISTRY } from '$lib/calculations/mechanic';
|
||||||
import type { AiMechanicAttribute } from './mechanic-authoring-context';
|
import type { AiMechanicAttribute } from './mechanic-authoring-context';
|
||||||
|
|
||||||
export const AI_MECHANIC_PROMPT_VERSION = 10;
|
export const AI_MECHANIC_PROMPT_VERSION = 11;
|
||||||
|
|
||||||
const mechanicsGrammar = {
|
const mechanicsGrammar = {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -83,6 +83,7 @@ const mechanicsGrammar = {
|
|||||||
'attribute',
|
'attribute',
|
||||||
'reducer',
|
'reducer',
|
||||||
'pool',
|
'pool',
|
||||||
|
'variant_value',
|
||||||
],
|
],
|
||||||
combatReferences: MECHANIC_COMBAT_REFERENCE_REGISTRY,
|
combatReferences: MECHANIC_COMBAT_REFERENCE_REGISTRY,
|
||||||
numericExpressionOperators: [
|
numericExpressionOperators: [
|
||||||
@@ -95,7 +96,9 @@ const mechanicsGrammar = {
|
|||||||
'clamp',
|
'clamp',
|
||||||
'floor',
|
'floor',
|
||||||
],
|
],
|
||||||
|
draftOutcomeExpressionReferences: ['variant_value'],
|
||||||
referenceShapes: [
|
referenceShapes: [
|
||||||
|
{ ref: { source: 'variant_value' } },
|
||||||
{ ref: { source: 'event', path: 'values.distanceMeters' } },
|
{ ref: { source: 'event', path: 'values.distanceMeters' } },
|
||||||
{ ref: { source: 'scenario', key: 'input-key' } },
|
{ ref: { source: 'scenario', key: 'input-key' } },
|
||||||
{ ref: { source: 'combat', path: 'currentHealth', timing: 'current' } },
|
{ ref: { source: 'combat', path: 'currentHealth', timing: 'current' } },
|
||||||
@@ -498,6 +501,7 @@ Security and scope:
|
|||||||
- Use only the supplied event, action, recipe, attribute, scope, reference, expression, reducer, mutation, and outcome catalogs.
|
- Use only the supplied event, action, recipe, attribute, scope, reference, expression, reducer, mutation, and outcome catalogs.
|
||||||
- Never invent an Attribute ID, event, field path, source variant, or runtime behavior.
|
- Never invent an Attribute ID, event, field path, source variant, or runtime behavior.
|
||||||
- Ask focused questions when different answers would change the calculation. Do not guess material cadence, duration, target, stacking, reset, scope, damage basis, or value behavior.
|
- Ask focused questions when different answers would change the calculation. Do not guess material cadence, duration, target, stacking, reset, scope, damage basis, or value behavior.
|
||||||
|
- An unresolved numbered source placeholder is a material missing value. Ask the human for the exact numeric value for every affected source variant; never infer or guess it.
|
||||||
- If the behavior cannot be represented by the supplied grammar, return capability_gap with implementation-ready developer changes.
|
- If the behavior cannot be represented by the supplied grammar, return capability_gap with implementation-ready developer changes.
|
||||||
|
|
||||||
Response rules:
|
Response rules:
|
||||||
@@ -507,6 +511,7 @@ Response rules:
|
|||||||
- All keys must be stable lower-case identifiers using letters, digits, dashes, or underscores and beginning with a letter.
|
- All keys must be stable lower-case identifiers using letters, digits, dashes, or underscores and beginning with a letter.
|
||||||
- A draft must define exactly the supplied value variants for every outcome.
|
- A draft must define exactly the supplied value variants for every outcome.
|
||||||
- The numeric variant value is the human-facing base value. Use expression only when runtime scaling or a live event/scenario/equipment value changes it.
|
- The numeric variant value is the human-facing base value. Use expression only when runtime scaling or a live event/scenario/equipment value changes it.
|
||||||
|
- Put each clarified placeholder answer in the corresponding outcome variant value as its human-facing value. When that value participates in a runtime expression, reference it exactly as {ref:{source:"variant_value"}}; never duplicate the numeric literal in the expression. variant_value is draft-only and valid only inside an outcome variant expression.
|
||||||
- Use attribute_modifier only with an exact supplied Attribute ID.
|
- Use attribute_modifier only with an exact supplied Attribute ID.
|
||||||
- Use direct_damage, restoration, or resource_cost rather than a fabricated Attribute when that is the actual mechanic.
|
- Use direct_damage, restoration, or resource_cost rather than a fabricated Attribute when that is the actual mechanic.
|
||||||
- Keep assumptions explicit. Use warnings for representable limitations that do not require code changes.
|
- Keep assumptions explicit. Use warnings for representable limitations that do not require code changes.
|
||||||
@@ -551,9 +556,14 @@ export function buildAiMechanicAuthoringInput(input: {
|
|||||||
'A target-scoped pool owns an independent value and expiry timer for each entity. Bind hit-created target state with entity:{kind:"event_target"}; do not collapse different enemies into one Agent-scoped pool.',
|
'A target-scoped pool owns an independent value and expiry timer for each entity. Bind hit-created target state with entity:{kind:"event_target"}; do not collapse different enemies into one Agent-scoped pool.',
|
||||||
'Writing a target-scoped pool refreshes only that target entity timer. An on_exit outcome runs when that entity expires and uses the entity previous state for stack bands and perStack multiplication, even though the stored pool value has already reset.',
|
'Writing a target-scoped pool refreshes only that target entity timer. An on_exit outcome runs when that entity expires and uses the entity previous state for stack bands and perStack multiplication, even though the stored pool value has already reset.',
|
||||||
'When an outcome value is exactly a live Attribute amount, use the Attribute reference directly as the variant expression. Divide by 100 only when a displayed percentage is being used as a multiplier.',
|
'When an outcome value is exactly a live Attribute amount, use the Attribute reference directly as the variant expression. Divide by 100 only when a displayed percentage is being used as a multiplier.',
|
||||||
|
'variant_value is draft-only and may appear only inside an outcome variant expression, with the exact shape {ref:{source:"variant_value"}}. It resolves to that same variant object’s human-facing numeric value at runtime.',
|
||||||
|
'If source wording contains an unresolved numbered placeholder such as {1} or {2}, return clarification_required and ask for the exact numeric answer for every affected source variant. Never guess a placeholder value.',
|
||||||
|
'After clarification, put each answer in the corresponding outcome variants[variantKey].value. If a runtime-scaled expression uses that answer, reference {ref:{source:"variant_value"}} rather than duplicating the numeric literal.',
|
||||||
'A weapon_hit may compare values.aiming with true only when the wording requires aiming down sights at hit resolution. Missing aiming evidence does not match; do not replace it with a scenario assumption.',
|
'A weapon_hit may compare values.aiming with true only when the wording requires aiming down sights at hit resolution. Missing aiming evidence does not match; do not replace it with a scenario assumption.',
|
||||||
'An explosion centered on the hit target uses target-scoped direct_damage with targeting kind event_target_area, center event_target, validTargets enemies, and the stated positive radiusMeters. The runtime applies one equal damage emission to each authoritative valid enemy at or inside the radius. Do not use legacy within_radius metadata for explosion damage.',
|
'An explosion centered on the hit target uses target-scoped direct_damage with targeting kind event_target_area, center event_target, validTargets enemies, and the stated positive radiusMeters. The runtime applies one equal damage emission to each authoritative valid enemy at or inside the radius. Do not use legacy within_radius metadata for explosion damage.',
|
||||||
'Precise Trauma is representable as one target-scoped stack pool with a one-second expiry: body shots add one stack, headshots add two, and one on_exit direct-damage outcome uses the expiring target previous stack count. This models the stated one-second debuff without inventing generic repeated target ticks.',
|
'Precise Trauma is representable as one target-scoped stack pool with a one-second expiry: body shots add one stack, headshots add two, and one on_exit direct-damage outcome uses the expiring target previous stack count. This models the stated one-second debuff without inventing generic repeated target ticks.',
|
||||||
|
'For proximity_snapshot, the optional values.alliesWithin30MetersWithAgentGrantedBuff field is a non-negative integer count of distinct allied Agents at or within 30 meters who possess at least one buff granted by the source Agent. Multiple qualifying buffs on one ally still count that ally once. Read it with {ref:{source:"event",path:"values.alliesWithin30MetersWithAgentGrantedBuff"}}.',
|
||||||
|
'When loadout timelines must supply values.alliesWithin30MetersWithAgentGrantedBuff, define the ready numeric scenario input exactly as {key:"allies-within-30m-with-agent-granted-buff",label:"Allies within 30 meters with an Agent-granted buff",kind:"number",readiness:"ready",min:0}. The loadout timeline copies that resolved count into each proximity_snapshot event, and runtime event validation rejects non-integers.',
|
||||||
'OS Protocol upgrade scaling may use equipment.protocolTalentUpgrades only when allowProtocolUpgradeScaling is true.',
|
'OS Protocol upgrade scaling may use equipment.protocolTalentUpgrades only when allowProtocolUpgradeScaling is true.',
|
||||||
'Assigned-ability scope may be used only when allowAssignedAbility is true.',
|
'Assigned-ability scope may be used only when allowAssignedAbility is true.',
|
||||||
'When allowAssignedAbility is true, a kill or assist that must occur while the source item\'s assigned ability is active uses enemy_killed or assist with {source:"event",path:"values.assignedAbilityActive"} compared to true. This field is optional on the event; a missing value does not match.',
|
'When allowAssignedAbility is true, a kill or assist that must occur while the source item\'s assigned ability is active uses enemy_killed or assist with {source:"event",path:"values.assignedAbilityActive"} compared to true. This field is optional on the event; a missing value does not match.',
|
||||||
|
|||||||
@@ -502,16 +502,12 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
|||||||
case 'skill-chips':
|
case 'skill-chips':
|
||||||
countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE item.active=1${searchClause}`;
|
countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE item.active=1${searchClause}`;
|
||||||
rowsSql = `SELECT item.*,bonus.min_value,bonus.max_value,attributes.name AS bonus_stat,attributes.unit AS bonus_unit,
|
rowsSql = `SELECT item.*,bonus.min_value,bonus.max_value,attributes.name AS bonus_stat,attributes.unit AS bonus_unit,
|
||||||
talents.name AS talent_name,abilities.name AS ability_name,specializations.name AS specialization_name,
|
talents.name AS talent_name,talents.description_template AS talent_description,
|
||||||
(SELECT COUNT(*) FROM skill_chip_primary_options) AS primary_count,
|
|
||||||
(SELECT COUNT(*) FROM skill_chip_secondary_options) AS secondary_count,
|
|
||||||
icons.updated_at AS icon_updated_at,render.id AS render_id,render.updated_at AS render_updated_at
|
icons.updated_at AS icon_updated_at,render.id AS render_id,render.updated_at AS render_updated_at
|
||||||
FROM skill_chip_sets item
|
FROM skill_chip_sets item
|
||||||
LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id
|
LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id
|
||||||
LEFT JOIN stat_attributes attributes ON attributes.id=bonus.stat_attribute_id
|
LEFT JOIN stat_attributes attributes ON attributes.id=bonus.stat_attribute_id
|
||||||
LEFT JOIN skill_chip_talents talents ON talents.skill_chip_set_id=item.id
|
LEFT JOIN skill_chip_talents talents ON talents.skill_chip_set_id=item.id
|
||||||
LEFT JOIN specialization_abilities abilities ON abilities.id=item.specialization_ability_id
|
|
||||||
LEFT JOIN specializations ON specializations.id=abilities.specialization_id
|
|
||||||
LEFT JOIN game_data_icons icons ON icons.entity_type='skill_chip' AND icons.entity_id=item.id
|
LEFT JOIN game_data_icons icons ON icons.entity_type='skill_chip' AND icons.entity_id=item.id
|
||||||
LEFT JOIN managed_images render ON render.source_entity_type='skill_chip_render' AND render.source_entity_id=item.id
|
LEFT JOIN managed_images render ON render.source_entity_type='skill_chip_render' AND render.source_entity_id=item.id
|
||||||
WHERE item.active=1${searchClause}
|
WHERE item.active=1${searchClause}
|
||||||
@@ -1035,23 +1031,12 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
|||||||
iconUrl: iconUrl('skill_chip', row.id, row.icon_updated_at),
|
iconUrl: iconUrl('skill_chip', row.id, row.icon_updated_at),
|
||||||
badges: [{ label: 'SUPERIOR' }, { label: 'HIGH-END' }],
|
badges: [{ label: 'SUPERIOR' }, { label: 'HIGH-END' }],
|
||||||
facts: [
|
facts: [
|
||||||
{
|
|
||||||
label: 'SPECIALIZATION ABILITY',
|
|
||||||
value: row.ability_name
|
|
||||||
? `${row.ability_name} · ${row.specialization_name}`
|
|
||||||
: 'UNASSIGNED',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: '2-PIECE BONUS',
|
label: '2-PIECE BONUS',
|
||||||
value: row.bonus_stat
|
value: row.bonus_stat
|
||||||
? `${row.bonus_stat} · ${range(row.min_value, row.max_value, row.bonus_unit)}`
|
? `${row.bonus_stat} · ${range(row.min_value, row.max_value, row.bonus_unit)}`
|
||||||
: 'NOT CONFIGURED',
|
: 'NOT CONFIGURED',
|
||||||
},
|
},
|
||||||
{ label: 'PRIMARY POOL', value: `${row.primary_count} OPTIONS` },
|
|
||||||
{
|
|
||||||
label: 'SECONDARY POOL',
|
|
||||||
value: `${row.secondary_count} OPTIONS`,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
lines: [
|
lines: [
|
||||||
{
|
{
|
||||||
@@ -1059,6 +1044,9 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
|||||||
value: list(row.talent_name, 'NOT CONFIGURED'),
|
value: list(row.talent_name, 'NOT CONFIGURED'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
description: row.talent_description
|
||||||
|
? String(row.talent_description)
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
case 'specializations':
|
case 'specializations':
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user