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