From 4eb754e7c8b49a062cfebad74a92cd2059126c31 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 27 Jul 2026 13:35:00 -0400 Subject: [PATCH] feat: add source-owned combat mechanic programs --- AGENTS.md | 18 + database/schema.sql | 179 +- docs/DESIGN_SYSTEM.md | 265 ++- docs/architecture.md | 397 ++-- scripts/db-migrate.mjs | 977 ++++++++- scripts/isac-data-export.mjs | 2 +- scripts/isac-data-import.mjs | 2 +- scripts/isac-data-tables.mjs | 10 +- src/app.css | 6 +- src/lib/builder/loadout-combat.test.ts | 144 ++ src/lib/builder/loadout-combat.ts | 850 +++++++- src/lib/builder/loadout.test.ts | 35 +- src/lib/builder/loadout.ts | 123 +- .../calculations/calculation-script.test.ts | 39 + src/lib/calculations/calculation-script.ts | 131 ++ src/lib/calculations/combat.test.ts | 32 +- src/lib/calculations/combat.ts | 64 +- src/lib/calculations/conditional.test.ts | 401 ++++ src/lib/calculations/conditional.ts | 964 +++++++++ src/lib/calculations/mechanic-editor.test.ts | 203 ++ src/lib/calculations/mechanic-editor.ts | 605 ++++++ .../calculations/mechanic-persistence.test.ts | 420 ++++ src/lib/calculations/mechanic-persistence.ts | 757 +++++++ src/lib/calculations/mechanic-recipes.test.ts | 156 ++ src/lib/calculations/mechanic-recipes.ts | 336 ++++ src/lib/calculations/mechanic.test.ts | 903 +++++++++ src/lib/calculations/mechanic.ts | 1743 +++++++++++++++++ src/lib/calculations/skill.test.ts | 268 +++ src/lib/calculations/skill.ts | 553 ++++++ .../components/MechanicLanguageHelp.svelte | 88 + .../components/ProposalChangePreview.svelte | 144 +- src/lib/components/PseudoScript.svelte | 54 + .../builder/CombatAssumptions.svelte | 92 +- .../builder/ConditionalEffectCard.svelte | 71 +- .../builder/SkillCalculationResult.svelte | 57 + .../builder/WeaponCalculationResult.svelte | 6 +- .../isac/ConditionalOutcomeEditor.svelte | 372 ++++ .../isac/MechanicProgramEditor.svelte | 735 +++++++ src/lib/server/audit.ts | 1 - src/lib/server/conditional-effects.test.ts | 76 + src/lib/server/conditional-effects.ts | 392 ++++ src/lib/server/data-moderation.ts | 7 +- src/lib/server/shared-build-combat.ts | 49 +- src/lib/toast.ts | 1 - src/routes/builder/+page.server.ts | 48 +- src/routes/builder/+page.svelte | 203 +- .../database/[category]/+page.server.ts | 78 +- src/routes/isac/calculations/+page.server.ts | 61 +- src/routes/isac/calculations/+page.svelte | 103 +- .../isac/data-moderation/+page.server.ts | 17 +- .../isac/mechanic-reference/+page.server.ts | 24 + .../isac/mechanic-reference/+page.svelte | 1061 ++++++++++ src/routes/isac/protocols/+page.server.ts | 127 +- src/routes/isac/protocols/+page.svelte | 62 +- src/routes/isac/skillchips/+page.server.ts | 152 +- src/routes/isac/skillchips/+page.svelte | 57 +- .../isac/specializations/+page.server.ts | 360 +++- src/routes/isac/specializations/+page.svelte | 139 +- src/routes/isac/talents/+page.server.ts | 193 +- src/routes/isac/talents/+page.svelte | 93 +- src/routes/isac/weapons/+page.server.ts | 164 +- src/routes/isac/weapons/+page.svelte | 50 +- 62 files changed, 14815 insertions(+), 905 deletions(-) create mode 100644 src/lib/builder/loadout-combat.test.ts create mode 100644 src/lib/calculations/calculation-script.test.ts create mode 100644 src/lib/calculations/calculation-script.ts create mode 100644 src/lib/calculations/conditional.test.ts create mode 100644 src/lib/calculations/conditional.ts create mode 100644 src/lib/calculations/mechanic-editor.test.ts create mode 100644 src/lib/calculations/mechanic-editor.ts create mode 100644 src/lib/calculations/mechanic-persistence.test.ts create mode 100644 src/lib/calculations/mechanic-persistence.ts create mode 100644 src/lib/calculations/mechanic-recipes.test.ts create mode 100644 src/lib/calculations/mechanic-recipes.ts create mode 100644 src/lib/calculations/mechanic.test.ts create mode 100644 src/lib/calculations/mechanic.ts create mode 100644 src/lib/calculations/skill.test.ts create mode 100644 src/lib/calculations/skill.ts create mode 100644 src/lib/components/MechanicLanguageHelp.svelte create mode 100644 src/lib/components/PseudoScript.svelte create mode 100644 src/lib/components/builder/SkillCalculationResult.svelte create mode 100644 src/lib/components/isac/ConditionalOutcomeEditor.svelte create mode 100644 src/lib/components/isac/MechanicProgramEditor.svelte create mode 100644 src/lib/server/conditional-effects.test.ts create mode 100644 src/lib/server/conditional-effects.ts create mode 100644 src/routes/isac/mechanic-reference/+page.server.ts create mode 100644 src/routes/isac/mechanic-reference/+page.svelte diff --git a/AGENTS.md b/AGENTS.md index 11aba1c..d4a526c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,24 @@ search-oriented option sets. Preserve native form semantics, keyboard access, focus-visible, selected, disabled, pressed, responsive, and reduced-motion behavior in shared controls. +## Prototype compatibility + +Treat application data and client payloads as prototype formats until the +project owner declares a production-stable format. During the prototype phase: + +1. Prefer one current validated schema over compatibility readers, repair + branches, dual writes, or fallback parsing for obsolete formats. +2. Remove incompatible local drafts, saved builds, calibration snapshots, and + other derived records through a backed-up migration when the current model + cannot represent them safely. +3. Preserve authored game data whenever the new model can retain it without + guessing. Mark incomplete mechanics for configuration instead of inventing + trigger parameters or effect values. +4. Follow the database-preservation rules before removing or reshaping + persistent data. +5. Add compatibility support only when the project owner requires it for a + production-stable format. + ## ISAC editor lifecycle All ISAC data-management editors must follow the same successful-save behavior: diff --git a/database/schema.sql b/database/schema.sql index 3566f50..0a5bd46 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -369,30 +369,123 @@ CREATE TABLE IF NOT EXISTS talent_tiers ( updated_at TEXT NOT NULL, UNIQUE(talent_id, tier) ); -CREATE TABLE IF NOT EXISTS talent_triggers ( +CREATE TABLE IF NOT EXISTS conditional_effect_rules ( id TEXT PRIMARY KEY, - name TEXT NOT NULL, - description TEXT NOT NULL, - supports_uptime INTEGER NOT NULL DEFAULT 0 CHECK(supports_uptime IN (0, 1)), - min_uptime REAL, - max_uptime REAL, - calculation_script TEXT, - active INTEGER NOT NULL DEFAULT 1, + source_type TEXT NOT NULL CHECK(source_type IN ( + 'talent','os_protocol_talent','skill_chip_talent','specialization_tree_rank' + )), + source_id TEXT NOT NULL, + configured INTEGER NOT NULL DEFAULT 0 CHECK(configured IN (0,1)), + script_version INTEGER NOT NULL DEFAULT 1 CHECK(script_version>0), + simulation_readiness TEXT NOT NULL DEFAULT 'ready' CHECK(simulation_readiness IN ( + 'ready','requires_assumptions','captured_only' + )), + scenario_inputs_json TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + UNIQUE(source_type,source_id) +); +CREATE TABLE IF NOT EXISTS conditional_effect_state_pools ( + id TEXT PRIMARY KEY, + rule_id TEXT NOT NULL REFERENCES conditional_effect_rules(id) ON DELETE CASCADE, + state_key TEXT NOT NULL, + label TEXT NOT NULL, + state_kind TEXT NOT NULL DEFAULT 'stack' CHECK(state_kind IN ( + 'flag','count','stack','duration','scalar','target_mark','resource' + )), + state_scope TEXT NOT NULL DEFAULT 'agent' CHECK(state_scope IN ('agent','target')), + initial_value REAL NOT NULL DEFAULT 0, + minimum_value REAL, + maximum_value REAL NOT NULL DEFAULT 1 CHECK(maximum_value>0), + best_case_value REAL, + duration_seconds REAL CHECK(duration_seconds IS NULL OR duration_seconds>0), + expiry_policy TEXT NOT NULL DEFAULT 'clear_all' CHECK(expiry_policy IN ('clear_all','decrement_one','none')), + refresh_policy TEXT NOT NULL DEFAULT 'refresh' CHECK(refresh_policy IN ('refresh','extend','ignore')), + parameters_json TEXT NOT NULL DEFAULT '{}', + sort_order INTEGER NOT NULL CHECK(sort_order>=1), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(rule_id,state_key), + UNIQUE(rule_id,sort_order), + CHECK(minimum_value IS NULL OR maximum_value>=minimum_value), + CHECK(best_case_value IS NULL OR minimum_value IS NULL OR best_case_value>=minimum_value), + CHECK(best_case_value IS NULL OR best_case_value<=maximum_value) +); +CREATE INDEX IF NOT EXISTS conditional_effect_state_pools_rule_idx + ON conditional_effect_state_pools(rule_id,sort_order); +CREATE TABLE IF NOT EXISTS conditional_effect_clauses ( + id TEXT PRIMARY KEY, + rule_id TEXT NOT NULL REFERENCES conditional_effect_rules(id) ON DELETE CASCADE, + clause_key TEXT NOT NULL, + label TEXT NOT NULL, + event_kind TEXT NOT NULL, + recipe_key TEXT, + recipe_parameters_json TEXT NOT NULL DEFAULT '{}', + conditions_json TEXT NOT NULL DEFAULT '{"kind":"true"}', + reducer_json TEXT NOT NULL DEFAULT '{"kind":"none"}', + weight_expression_json TEXT, + emission_kind TEXT NOT NULL DEFAULT 'each_match' CHECK(emission_kind IN ( + 'each_match','threshold_crossed','while_true','interval_elapsed','value_changed' + )), + state_pool_id TEXT REFERENCES conditional_effect_state_pools(id) ON DELETE SET NULL, + mutations_json TEXT NOT NULL DEFAULT '[]', + cooldown_seconds REAL CHECK(cooldown_seconds IS NULL OR cooldown_seconds>0), + output_branch TEXT NOT NULL DEFAULT 'active', + sort_order INTEGER NOT NULL CHECK(sort_order>=1), + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(rule_id,clause_key), + UNIQUE(rule_id,sort_order) +); +CREATE INDEX IF NOT EXISTS conditional_effect_clauses_event_idx + ON conditional_effect_clauses(event_kind,rule_id); +CREATE TABLE IF NOT EXISTS conditional_effect_outcomes ( + id TEXT PRIMARY KEY, + rule_id TEXT NOT NULL REFERENCES conditional_effect_rules(id) ON DELETE CASCADE, + clause_id TEXT REFERENCES conditional_effect_clauses(id) ON DELETE CASCADE, + state_pool_id TEXT REFERENCES conditional_effect_state_pools(id) ON DELETE SET NULL, + effect_key TEXT NOT NULL, + variant_key TEXT NOT NULL DEFAULT 'base', + outcome_key TEXT NOT NULL DEFAULT 'active', + application_kind TEXT NOT NULL DEFAULT 'active_modifier' CHECK(application_kind IN ( + 'active_modifier','direct_damage','restore_health','restore_armor','restore_ammo','resource_cost','marker' + )), + effect_scope TEXT NOT NULL DEFAULT 'self' CHECK(effect_scope IN ( + 'self','equipped_weapon','target','allies','self_and_allies','assigned_ability','all_skills' + )), + stat_attribute_id TEXT REFERENCES stat_attributes(id), + value REAL NOT NULL, + value_expression_json TEXT, + value_basis TEXT NOT NULL DEFAULT 'flat' CHECK(value_basis IN ( + 'flat','max_health_percent','current_health_percent','weapon_base_percent' + )), + value_scaling TEXT NOT NULL DEFAULT 'per_state_unit' CHECK(value_scaling IN ('flat','per_state_unit')), + unit TEXT, + damage_basis TEXT CHECK(damage_basis IS NULL OR damage_basis IN ('flat','weapon_base_percent')), + application_phase TEXT NOT NULL DEFAULT 'while_active' CHECK(application_phase IN ( + 'while_active','on_event','after_event','on_enter','on_exit' + )), + combat_timing TEXT NOT NULL DEFAULT 'current' CHECK(combat_timing IN ('pre_event','current')), + resource_kind TEXT CHECK(resource_kind IS NULL OR resource_kind IN ('health','armor','ammo')), + min_state_value REAL, + max_state_value REAL, + description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1), + sort_order INTEGER NOT NULL CHECK(sort_order>=1), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(rule_id,variant_key,outcome_key,sort_order), + CHECK(min_state_value IS NULL OR min_state_value>=0), + CHECK(max_state_value IS NULL OR max_state_value>=0), + CHECK(min_state_value IS NULL OR max_state_value IS NULL OR max_state_value>=min_state_value), CHECK( - (supports_uptime = 0 AND min_uptime IS NULL AND max_uptime IS NULL) - OR - (supports_uptime = 1 AND min_uptime IS NOT NULL AND max_uptime IS NOT NULL - AND min_uptime >= 0 AND max_uptime <= 100 AND min_uptime <= max_uptime) + (application_kind='active_modifier' AND stat_attribute_id IS NOT NULL AND damage_basis IS NULL) + OR (application_kind='direct_damage' AND damage_basis IS NOT NULL) + OR application_kind IN ('restore_health','restore_armor','restore_ammo','resource_cost','marker') ) ); -CREATE UNIQUE INDEX IF NOT EXISTS talent_triggers_name_nocase_idx ON talent_triggers(name COLLATE NOCASE); -CREATE TABLE IF NOT EXISTS talent_tier_triggers ( - talent_tier_id TEXT NOT NULL REFERENCES talent_tiers(id) ON DELETE CASCADE, - trigger_id TEXT NOT NULL REFERENCES talent_triggers(id), - PRIMARY KEY(talent_tier_id, trigger_id) -); +CREATE INDEX IF NOT EXISTS conditional_effect_outcomes_rule_idx + ON conditional_effect_outcomes(rule_id,variant_key,outcome_key,sort_order); CREATE TABLE IF NOT EXISTS talent_tier_effects ( id TEXT PRIMARY KEY, talent_tier_id TEXT NOT NULL REFERENCES talent_tiers(id) ON DELETE CASCADE, @@ -604,11 +697,6 @@ CREATE TABLE IF NOT EXISTS os_protocol_talents ( updated_at TEXT NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS os_protocol_talents_name_nocase_idx ON os_protocol_talents(name COLLATE NOCASE); -CREATE TABLE IF NOT EXISTS os_protocol_talent_triggers ( - talent_id TEXT NOT NULL REFERENCES os_protocol_talents(id) ON DELETE CASCADE, - trigger_id TEXT NOT NULL REFERENCES talent_triggers(id), - PRIMARY KEY(talent_id, trigger_id) -); CREATE TABLE IF NOT EXISTS os_protocol_talent_effects ( id TEXT PRIMARY KEY, talent_id TEXT NOT NULL REFERENCES os_protocol_talents(id) ON DELETE CASCADE, @@ -655,11 +743,6 @@ CREATE TABLE IF NOT EXISTS skill_chip_talents ( updated_at TEXT NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS skill_chip_talents_name_nocase_idx ON skill_chip_talents(name COLLATE NOCASE); -CREATE TABLE IF NOT EXISTS skill_chip_talent_triggers ( - talent_id TEXT NOT NULL REFERENCES skill_chip_talents(id) ON DELETE CASCADE, - trigger_id TEXT NOT NULL REFERENCES talent_triggers(id), - PRIMARY KEY(talent_id, trigger_id) -); CREATE TABLE IF NOT EXISTS skill_chip_talent_effects ( id TEXT PRIMARY KEY, talent_id TEXT NOT NULL REFERENCES skill_chip_talents(id) ON DELETE CASCADE, @@ -857,6 +940,26 @@ CREATE TABLE IF NOT EXISTS specialization_abilities ( updated_at TEXT NOT NULL, UNIQUE(specialization_id,name) ); +CREATE TABLE IF NOT EXISTS specialization_ability_attacks ( + ability_id TEXT PRIMARY KEY REFERENCES specialization_abilities(id) ON DELETE CASCADE, + base_damage REAL NOT NULL CHECK(base_damage>=0), + damage_type TEXT NOT NULL CHECK(damage_type IN ('Blast','Pierce','Shredding')), + delivery TEXT NOT NULL CHECK(delivery IN ('direct','periodic')), + hits_per_activation INTEGER NOT NULL DEFAULT 1 CHECK(hits_per_activation>0), + tick_interval_seconds REAL CHECK(tick_interval_seconds IS NULL OR tick_interval_seconds>0), + damage_duration_seconds REAL CHECK(damage_duration_seconds IS NULL OR damage_duration_seconds>0), + cast_seconds REAL NOT NULL DEFAULT 0 CHECK(cast_seconds>=0), + target_cap INTEGER NOT NULL DEFAULT 1 CHECK(target_cap>0), + charge_count INTEGER NOT NULL DEFAULT 1 CHECK(charge_count>0), + charge_policy TEXT NOT NULL DEFAULT 'none' CHECK(charge_policy IN ('none','independent','full_set')), + updated_at TEXT NOT NULL, + CHECK( + (delivery='direct' AND tick_interval_seconds IS NULL AND damage_duration_seconds IS NULL) + OR + (delivery='periodic' AND tick_interval_seconds IS NOT NULL AND damage_duration_seconds IS NOT NULL) + ), + CHECK(charge_policy<>'none' OR charge_count=1) +); CREATE INDEX IF NOT EXISTS specialization_abilities_specialization_idx ON specialization_abilities(specialization_id,name); CREATE TABLE IF NOT EXISTS specialization_ability_effects ( id TEXT PRIMARY KEY, @@ -1010,6 +1113,26 @@ CREATE TABLE IF NOT EXISTS attribute_calculation_profiles ( CHECK(hard_cap IS NULL OR hard_cap>=0), CHECK(soft_cap IS NULL OR hard_cap IS NULL OR hard_cap>=soft_cap) ); +CREATE TABLE IF NOT EXISTS skill_attribute_calculation_profiles ( + id TEXT PRIMARY KEY, + revision_id TEXT NOT NULL REFERENCES calculation_revisions(id) ON DELETE CASCADE, + stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id), + stage_key TEXT NOT NULL CHECK(stage_key IN ( + 'flat_damage','engineering','skill_damage','critical_chance','critical_damage', + 'multishot_chance','multishot_damage','final_damage', + 'cooldown_recovery','duration' + )), + operator TEXT NOT NULL CHECK(operator IN ('add','multiply')), + weight REAL NOT NULL DEFAULT 1, + soft_cap REAL, + hard_cap REAL, + curve_type TEXT NOT NULL DEFAULT 'none' CHECK(curve_type IN ('none','power','smoothstep')), + curve_parameter REAL, + UNIQUE(revision_id,stat_attribute_id,stage_key), + CHECK(soft_cap IS NULL OR soft_cap>=0), + CHECK(hard_cap IS NULL OR hard_cap>=0), + CHECK(soft_cap IS NULL OR hard_cap IS NULL OR hard_cap>=soft_cap) +); CREATE TABLE IF NOT EXISTS calculation_calibration_cases ( id TEXT PRIMARY KEY, name TEXT NOT NULL, diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 6a8e041..d839235 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -713,7 +713,7 @@ augmentation cell communicates that this talent advances to Tier II. The exotic talent is owned by exactly one exotic weapon and is authored inline inside that weapon editor, never selected from a shared global pool. Its engineered sub-panel includes the talent name, shared description template, -Tier I/II previews, reusable triggers, and paired effect values. The Talent +Tier I/II previews, and the shared source-owned mechanic program editor. The Talent Registry may display an owned exotic talent for discovery, but routes editing back to its weapon rather than opening the shared-talent editor. @@ -816,9 +816,9 @@ the base until five upgrades show exactly twice the base effect. Diamonds are both a progression preview and an explicit numeric state; expose their upgrade number and `aria-pressed` state rather than relying on fill color. -Protocol talents own their effects but reuse the global stat and trigger -registries. Shared secondary values do not scale with the individual protocol’s -level. Store only the unique primary and talent base values, and derive their +Protocol Talents own their mechanic programs and reuse the global Attribute +registry. Shared secondary values do not scale with the individual protocol’s +level. Store only the unique primary and Talent base values, and derive their progression consistently in calculation and display code. ### ISAC editor lifecycle @@ -835,16 +835,16 @@ text is operationally useful. Use compact wrapping description cells rather than replacing meaningful data with a generic “configured” label; retain an explicit textual missing state when a tier has no rendered description. -Talent mechanics are structured metadata, not free-form prose. Present optional -cooldown, duration, and maximum-stack inputs together in a compact three-column -mechanics strip that collapses to one column on narrow screens. Description -templates use numbered expressions (`{1}`, `{2}`) for tiered attribute effects -and named expressions (`{duration}`, `{max_stacks}`) for shared mechanics. -Named expressions are valid only when their corresponding value is defined. -Cooldown is never embedded in the description: render it consistently as -`Talent Cooldown: [value] seconds` beneath the talent identity or description. -Do not imply an absent mechanic with a zero value; show no cooldown line and -leave optional editor fields explicitly marked as not defined. +Talent mechanics are structured metadata, not free-form prose. Author +cooldowns on event branches, durations and maximum stacks on state pools, and +tier values on outcomes through `MechanicProgramEditor`. Description templates +use numbered expressions (`{1}`, `{2}`) for ordered outcomes and named +expressions (`{duration}`, `{max_stacks}`) for defined pool mechanics. Named +expressions are valid only when their corresponding value exists. Cooldown is +never embedded in the description: render it consistently as `Talent +Cooldown: [value] seconds` beneath the talent identity or description. Do not +imply an absent mechanic with a zero value; omit the cooldown line when no +branch defines one. ### Registry pagination @@ -916,8 +916,8 @@ bookmarkable, and expose the selected state with both orange emphasis and `aria-current`. Cards require pointer, hover, focus-visible, pressed, and textually identifiable active states. Every card explicitly says `FILTER →` or `SELECTED`; do not rely on KPI-card styling alone to communicate that the -metrics are controls. Counts describe the full search scope, -not merely the visible page. Replace aggregate facts that cannot filter the +metrics are controls. Counts describe the full search scope and never only the +visible page. Replace aggregate facts that cannot filter the affected registry with actionable subsets. ### Registry row interaction @@ -963,8 +963,8 @@ Show contributor access banners only on Data Management modules. Show the data-mode controls and language from User Operations, System Oversight, and System Configuration because their permissions govern access independently. Read-only mode is an inspection surface: hide add controls, row edit actions, -mutation forms, and editors opened through URL state. Do not merely dim or -disable them. Registry navigation, KPI filters, pagination, search, evidence, +mutation forms, and editors opened through URL state. Dimming or disabling +those controls is insufficient. Registry navigation, KPI filters, pagination, search, evidence, and other non-mutating inspection controls remain visible and interactive. The ISAC command-status cluster includes system state, access state, the themed @@ -1293,8 +1293,8 @@ The primary `name` field in each ISAC data editor is a 46-pixel header-level input with larger, heavier text. It gives long registries a consistent first reading point while keeping secondary metadata compact. -`SearchableAssignment` is the standard relationship picker for long attribute, -talent, trigger, and compatibility lists. Multiple mode manages additive sets; +`SearchableAssignment` is the standard relationship picker for long Attribute, +Talent, event, and compatibility lists. Multiple mode manages additive sets; single mode presents the same searchable selected/result treatment for one relationship. Prefer these modes over native selects once a source registry is large enough that scanning a dropdown is inefficient. The selected record, @@ -1397,10 +1397,11 @@ such as the single-exotic-weapon rule are explained rather than hidden. Numeric instance values display their authoritative minimum and maximum beside the field and clamp to that range on completion. Always-on item rolls, fixed bonuses, inherited profiles, enabled augments, and activated set bonuses feed -the live totals immediately. Trigger-dependent talents remain explicitly -`CONDITIONAL` until the user enables `TRIGGER ACTIVE`; only then may their -effects enter combined totals. This textual distinction must remain clear -without relying on color. +the live totals immediately. Conditional mechanics state their calculation +mode and readiness. Maximum Potential uses their best-case state; Custom +Scenario uses the source-defined advanced assumptions saved with the loadout. +Exclude a mechanic that lacks a required assumption and label it `REQUIRES +ASSUMPTIONS`. This distinction must remain clear without relying on color. ### Pending ISAC registry records @@ -1844,23 +1845,165 @@ positive whole-number quantities for Rank 2. Rank 3 uses the yellow High-end Omega treatment and exactly one component. Color is supporting hierarchy; quality names and quantities always remain visible. -### Conditional combat modeling +### Source-owned mechanic program editor -ISAC authors define a combat effect once per Specialization and attach it to -one or more tree-node ranks. Each rank attachment owns its hit trigger, stack -duration, stack limit, and Attribute modifiers. Keep the lifecycle rules in a -compact fact rail: trigger counters reset after a proc, each stack gain refreshes -the timer, and expiry clears the complete stack. The shared definition provides -one effect name and description to every attached rank. +Use `MechanicProgramEditor` for every calculation-ready standard or exotic +Talent, OS Protocol Talent, Skill Chip Talent, and Specialization tree-rank +effect. The program stays inside its owning source editor and contribution +payload. Do not create a standalone Trigger registry, Trigger assignment +control, or shared Trigger lifecycle. -Use `--shd-model-surface` and `--shd-model-border` for conditional mechanics, +Each source supplies the value columns required by its progression: + +- Standard and exotic Talents show Tier I and Tier II. +- OS Protocol Talents show a base value and may scale an outcome through the + protocol's zero-to-five upgrade expression. +- Skill Chip Talents show minimum and maximum values. +- Specialization tree ranks show the rank's base value. + +The editor is one layered control surface. Start with a compact program heading +and the explicit `READY`, `REQUIRES ASSUMPTIONS`, or `CAPTURED ONLY` state. +Follow it with Builder Controls, Recipe Starter, State Pools, ordered event +branches, ordered outcomes, and the generated read-only script. Use connected +panels and restrained borders to preserve the hierarchy; do not present the +typed graph as an undifferentiated form. + +The direct-event picker reads the 19 normalized events from the code-defined +event registry. Use searchable single-assignment because the vocabulary exceeds +the compact segmented-choice limit. Each branch presents its event, match +policy and parameters, optional cooldown, state mutations, and outcomes. +Recipe-generated predicates and weights remain visible in the generated +script. State rows expose label, agent or target scope, initial value, bounds, +best-case value, and optional expiry. 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 +values, event fields, reducer output, source-defined scenario controls, and OS +Protocol upgrade scaling where the source permits it. + +Action Family recipes are optional authoring starters. Present the recipe +catalog as a searchable assignment with only the parameters required by the +selected family. `ADD RECIPE BRANCH` copies typed branches into the current +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 advanced scenario assumptions. Authors +choose toggle, slider, or numeric input and provide a label, Agent guidance, +default value, best-case value, and numeric bounds when applicable. Group these +controls behind the builder's advanced scenario surface. Maximum Potential +uses best-case values. Custom Scenario uses saved Agent values. A required +cadence or environmental value with no supplied assumption keeps the mechanic +out of the calculation and displays `REQUIRES ASSUMPTIONS`; the editor and +runtime never invent a cadence. + +Use `--shd-model-surface` and `--shd-model-border` for mechanic programs, calculation revisions, and field calibration. The blue information rail marks -modeled behavior. Orange remains reserved for selection and activation. Every -effect card names its trigger, duration, maximum stacks, and value per stack in -text so the mechanic remains clear without color. +modeled behavior. Orange remains reserved for selection and activation. Name +events, branches, state, cooldowns, durations, stack limits, outcomes, phases, +and per-stack values in text so the model remains legible without color. -The loadout builder gives each unlocked conditional effect three calculation -modes: +At narrow widths, stack panels, branch controls, state rows, variant columns, +and outcomes without changing their order. Keep add, remove, replace, and help +controls at least 44 pixels tall. Every disclosure and confirmation exposes +its state to assistive technology. Focus remains in the active field when the +generated script refreshes, and reduced-motion preferences remove expansion +and replacement transitions. + +#### Generated mechanic script + +The shared `PseudoScript` component gives authors and builders a readable +transcription of the typed mechanic or combat calculation. The typed event, +predicate, reducer, state-pool, outcome, and calculation-stage data remain +authoritative. The interface generates the script from that data; authors do +not edit or execute the displayed text. + +Render generated script on `--shd-model-surface` with +`--shd-model-border`, a blue information rail, restrained green syntax +keywords, line numbers, and the shared monospaced typeface. Give the panel an +explicit `GENERATED SCRIPT · READ ONLY` label and show its calculation +readiness in text. Reserve orange for adjacent actions and selected controls. +Do not style the panel as an editable textarea or show a caret, resize handle, +or save action. + +Use semantic code content inside the panel. Treat line numbers as structural +decoration and ensure assistive technology reads each statement once. Preserve +indentation and keep type at the telemetry floor. Long statements may wrap at +token boundaries or use horizontal scrolling on narrow widths. Keep the panel +within its parent grid with `min-width: 0`. Script regeneration must not move +focus or announce the complete script after each field change. Place validation +and readiness messages beside the panel with ordinary status semantics. + +Use the shared `MechanicLanguageHelp` control wherever an author can configure +mechanics or calculation stages. The compact `SCRIPT REFERENCE` button opens a +native help dialog with syntax, event, timing, state, and outcome references. +Long registry-level references may use a labeled disclosure with the same +content. The help trigger remains near the generated preview so authors do not +need to leave the editor to interpret it. Point every full-reference action to +`/isac/mechanic-reference`, with a stable section anchor when the source has a +specific topic. Do not point full-reference actions to collapsed same-page +anchors. + +Help triggers and disclosure summaries use native button semantics, +focus-visible treatment, and explicit expanded or dialog state. Opening a +dialog moves focus to its close control; Escape and the close action dismiss +it and return focus to the trigger. On mobile, the dialog fits the viewport, +scrolls its body, and keeps its close action available. Reduced-motion +preferences remove dialog and disclosure transitions. Syntax color and motion +never carry meaning without text. + +#### Mechanic reference surface + +`/isac/mechanic-reference` is the dedicated read surface for the mechanic and +combat-calculation languages. It contains no mutation controls. Give the page a +compact section index beside the reference body and keep that index sticky when +the available width and viewport height support it. Assign a stable heading +anchor to each major section so editor dialogs can link to the relevant +definition. + +Present bounded syntax, state, phase, outcome, and calculation-stage +vocabularies as connected reference matrices with shared borders and aligned +columns. Generate the 19-event registry and Action Family recipe catalog from +code-defined metadata so the reference matches the available editor controls. +Explain that events are runtime contracts and recipes only seed editable source +branches. Pair complex mechanics with a worked `PseudoScript` example. Keep +labels and descriptions visible; color, abbreviations, and script keywords +cannot carry the explanation alone. + +Include a live-searchable Attribute variable glossary sourced from the active +ISAC Attribute registry. Search matches Attribute names, stored descriptions, +types, categories, calculation types, units, source ranges, and caps. Display +each stored Attribute description verbatim. Show type, category, calculation +type, unit, applicable Gear, Weapon, and Skill Chip ranges, and optional cap as +supporting metadata. Mark a missing description as `DOCUMENTATION NEEDED` and +link authorized authors to the Attribute record; do not generate substitute +copy. Search operates on the full active glossary before any result limit and +keeps its visible label, clear action, keyboard focus, and textual result count. + +Section-index links and anchored headings use visible keyboard focus. In-page +navigation must place the target below the application header. At narrow +widths, move the index into document flow above the content and stack reference +sections in one column. Allow connected matrices to scroll within their own +boundaries, and prevent horizontal page overflow. Reduced-motion preferences +remove smooth anchor scrolling and sticky-state transitions. + +#### Builder calculation modes + +The loadout builder places one required two-option `SegmentedChoice` above +combat assumptions. The persisted `maximum` and `custom` values use these +visible labels: + +- `MAXIMUM POTENTIAL` calculates a configured ceiling. It applies conditional + effects at peak state and treats selected abilities as firing on cooldown + against their maximum configured target count. +- `CUSTOM SCENARIO` exposes encounter assumptions and source-defined advanced + controls for a player-defined scenario. + +New loadouts select `MAXIMUM POTENTIAL`. The interface labels Maximum results +as a modeled ceiling so players do not read them as typical encounter +performance. Maximum hides granular assumption fields without deleting their +saved values. Returning to Custom restores those values. + +Custom mode gives each unlocked conditional effect three state controls: - `OFF` excludes the effect. - `PEAK` applies its configured maximum stacks. @@ -1868,17 +2011,27 @@ modes: reload time, hit rate, firing activity, and encounter duration. Users may override encounter assumptions on one effect without changing the -global combat profile. The effect card shows the applied stack state beside the -mode. On mobile, assumption fields and effect controls stack into full-width -44-pixel targets. +global Custom profile. Open the advanced scenario surface to show only the +toggle, slider, and numeric controls defined by that effect's owning source. +The effect card shows uptime, activation count, and applied stack state beside +the mode. A mechanic that needs an undefined cadence or another missing +scenario input displays `REQUIRES ASSUMPTIONS`; the builder withholds its +calculated contribution until the author or player supplies that input. + +Maximum and Custom use radio semantics, an explicit selected label, keyboard +arrow navigation, and the standard orange selected rail. Results repeat the +active calculation mode in text. On mobile, the global mode control and Custom +assumption fields stack into full-width targets of at least 44 pixels. +Mode changes use the standard pressed feedback; reduced-motion preferences +remove transitions without hiding the state change. Combat result panels show normal, critical, headshot, and critical-headshot values with burst, sustained, and encounter DPS. Conditional stack telemetry -lists the mode, average and peak stacks, ending stacks, buildup time, and -maintained state that produced the result. A result with missing equipment, -Attribute bindings, rules, or assumptions displays `INSUFFICIENT DATA` and a -concrete remediation list. The interface withholds combat numbers until it has -the required inputs. +lists the mode, uptime, activations, average and peak stacks, ending stacks, +buildup time, and maintained state. A result with missing equipment, Attribute +bindings, rules, or assumptions displays `INSUFFICIENT DATA` and a concrete +remediation list. The interface withholds combat numbers until it has the +required inputs. ### Specialization loadout composer @@ -1893,10 +2046,16 @@ ranks remain visible with their lock state. The builder permits a row after the user buys every available Rank 1 and Rank 2 in preceding rows and enforces the 49-point limit. Cost telemetry lists Credits and components beside each rank. -Saved loadouts store focus, node ranks, nine Skill Chips, effect modes, and -combat assumptions. The builder accepts only the current complete structure. -Invalid local drafts are discarded; invalid server records are unavailable and -removed through a backed-up database migration. +Each ability panel exposes one compact activation control: Off, On Cooldown, or +Custom Rate. Custom Rate adds uses per minute. Every enabled +ability has its own target count. Skill result cards show the ability name, +activation count, single-target DPS, total-target DPS, and a combined summary. +They state that Skills can critically hit and multi-shot but cannot headshot. + +Saved loadouts store focus, node ranks, nine Skill Chips, conditional modes, +encounter assumptions, and per-ability activation profiles. The builder accepts +only the current complete structure. Invalid local drafts are discarded; +invalid server records are removed through a backed-up database migration. ### Calculation revision controls @@ -1906,6 +2065,14 @@ requires `calculation.publish`, an explicit review state, and a confirmation that saved builds will recalculate. Successful activation closes the confirmation surface and records the revision in the audit history. +Keep weapon and Skill Attribute profiles in separate compact registries inside +the same revision editor. A Skill profile shows the Attribute, one of the +bounded Skill stages, Add or Multiply, and its weight. Use searchable +assignment for the ten-stage vocabulary and a segmented control for the +two-value operator. Reject duplicate Attribute and stage pairs. Revision +cloning, proposal review, publication evidence, and audit snapshots must +include both profile registries. + Calibration cases pair a loadout snapshot with measured hit and timing values. Ready cases require the four hit values, magazine time, and reload time. Assisted suggestions create a draft revision for review and leave activation diff --git a/docs/architecture.md b/docs/architecture.md index b8eee3c..3caa8a8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,8 +1,8 @@ # SHD.build architecture -The project is built in vertical slices. +The team builds the project in vertical slices. -## Planned stack +## Application stack - SvelteKit 2 / Svelte 5 with TypeScript - Embedded Turso Database via `@tursodatabase/database` for durable application data @@ -18,14 +18,13 @@ The project is built in vertical slices. - Editor: manage builder source data. - Admin: manage users, roles, source data, and audit history. -Server code checks authorization for each mutation. Hiding an admin link does -not grant authorization. +Server code checks authorization for each mutation. UI visibility has no effect +on server authorization. -Weapon and gear range calculations must preserve unrounded decimal endpoints -and follow the empirically validated progression rules in -[`STAT_LEVEL_SCALING.md`](STAT_LEVEL_SCALING.md). In particular, leveling is -linear rather than compounded and game-matching integer values are truncated -only at the presentation boundary. +Weapon and gear range calculations must preserve decimal endpoints +and follow the tested progression rules in +[`STAT_LEVEL_SCALING.md`](STAT_LEVEL_SCALING.md). Leveling is linear rather than +compounded. The presentation layer truncates game-matching integer values. Local credentials use Argon2id PHC strings with a unique random salt per password. Current work factors meet OWASP's 19 MiB / 2 iteration / single-lane @@ -40,7 +39,7 @@ Turso Cloud, libSQL HTTP, remote replicas, auth tokens, or synchronization. The database defaults to `./data/shd-build.db`. Production deployments must provide a persistent writable filesystem and can override the location with `TDR_DATABASE_PATH`. This rules out ephemeral serverless filesystems unless a -persistent volume is attached. +deployment attaches a persistent volume. ## Site launch controls @@ -94,7 +93,7 @@ closed. 7. Admin editors, validation, audit log, and data import/export. # Administrator bootstrap -The first successfully authenticated user is promoted to `admin` if the +The authentication service promotes the first authenticated user to `admin` if the database does not contain an administrator. Promotion uses one conditional database update so concurrent first sign-ins cannot both become administrators. Once an administrator exists, later sign-ins never alter roles. @@ -117,7 +116,7 @@ npm run db:backup:production ``` The scripts write versioned JSON documents beneath `data/backups/` by default. -That directory is ignored by Git because backups contain private user data, +Git ignores that directory because backups contain private user data, including password hashes. To restore, first create an empty database and apply the schema. Then run: @@ -152,30 +151,30 @@ specific action, target entity, timestamp, and useful before/after evidence. The audit registry is read-only and requires `audit.view`; the seeded Auditor role grants that permission without granting mutation capabilities. -Audit history is immutable. “Revert” is implemented as a new compensating +Audit history is immutable. The server implements “Revert” as a new compensating mutation rather than deletion or modification of the original event: -1. The operator must independently hold `audit.revert`. -2. The original action must be explicitly supported and contain a usable +1. The operator must hold `audit.revert` in addition to any other access. +2. The reversion registry must support the original action and the event must contain a usable before-snapshot. 3. The UI requires a second, explicit confirmation. 4. The server reloads the target and compares it with the original after-snapshot, ignoring bookkeeping timestamps. A mismatch indicates newer work and blocks automatic compensation. -5. Referenced roles, permissions, and relationships are revalidated before - restoration. Restoring administrator authority additionally requires full +5. The server revalidates referenced roles, permissions, and relationships before + restoration. Restoring administrator authority also requires full administrative access. 6. The server restores the previous state and appends an `audit.reverted` event whose target is the original audit-event ID. -7. A reverted event cannot be reverted again. +7. The server rejects a second reversion of the same event. # Moderated game-data contributions -ISAC data access is split into `data.view`, `data.propose`, `data.manage`, and +ISAC data access uses `data.view`, `data.propose`, `data.manage`, and `data.approve`. Data Viewers can inspect registries. Data Contributors can submit -the same editor forms, but the request is serialized into `data_change_proposals` -instead of mutating canonical tables. Data Oversight reviews the queue and must -explicitly confirm approval or rejection. +the same editor forms, but the server serializes each request into +`data_change_proposals` instead of mutating canonical tables. Data Oversight +reviews the queue and must confirm approval or rejection. Approval replays the stored form through its original SvelteKit server action. This preserves canonical validation, relational updates, icon processing, and the @@ -190,12 +189,18 @@ Data Oversight combines direct-write access with queue review. New ISAC data modules must use the shared moderation interception path and audit both the final mutation and proposal lifecycle. -Every authenticated, active registered agent receives `isac.access`, +Mechanic programs remain part of their owning source record. Talent, OS +Protocol, Skill Chip, and Specialization proposals carry the complete typed +program with the rest of the source payload. Oversight reviews and approves +that program through the source module's canonical validation path. There is +no standalone Trigger proposal module or pending Trigger dependency. + +Each authenticated, active registered agent receives `isac.access`, `data.view`, and `data.propose` as baseline permissions in the authorization -layer. This baseline is intentionally implicit rather than a removable role -assignment, so it applies consistently to existing users and every account -creation method. Registered agents can inspect live source data and submit -moderated proposals, but cannot directly mutate canonical records, review the +layer. The authorization layer grants this baseline without a removable role +assignment, so existing users and each account-creation method receive the same +access. Registered agents can inspect live source data and submit +moderated proposals, but cannot mutate canonical records, review the queue, or access unrelated ISAC operations without additional permissions. Unsupported destructive identity operations, account deletion, incomplete snapshots, stale targets, and events whose dependencies no longer exist are @@ -204,7 +209,7 @@ specialized workflow rather than inferred database writes. # System metrics -ISAC uses `/isac` as its canonical route namespace. The server permanently +ISAC uses `/isac` as its canonical route namespace. The server redirects legacy `/admin` URLs to their matching `/isac` location so old notifications and external bookmarks remain usable. @@ -217,10 +222,10 @@ provide application record volumes. The interface refreshes on demand and may poll every ten seconds while the operator keeps auto-refresh enabled. CPU and event-loop values are deltas from -the previous server-side sample, so the first or infrequently requested sample -is directional rather than a monitoring-grade time series. Production alerting -and historical analysis should eventually use a dedicated external telemetry -system. This surface deliberately omits secrets, environment-variable values, +the previous server-side sample, so the first sample and samples separated by +long gaps provide direction rather than a monitoring-grade time series. Production alerting +and historical analysis should use a dedicated external telemetry +system. This surface omits secrets, environment-variable values, and full database paths. The same surface reports remove.bg paid credits and free API calls as separate @@ -234,27 +239,27 @@ last known balance and never expose the API key or raw response body. # Weapon-owned exotic talents -Standard weapon talent pools remain globally reusable and classified by Talent -1 / Top or Talent 2 / Bottom. An exotic talent is different: one exotic weapon -owns one dedicated talent, referenced by `weapons.exotic_talent_id`. +Standard weapon talent pools remain reusable across weapons and use Talent 1 / +Top or Talent 2 / Bottom. One exotic weapon owns each exotic talent through +`weapons.exotic_talent_id`. The weapon editor creates and updates the underlying normalized `talents`, -`talent_tiers`, trigger links, effect links, description template, and Exotic -weapon scope together with the weapon. Server validation rejects an exotic -talent ID not already owned by the edited weapon and rejects any talent shared -by another weapon. Converting a weapon back to standard clears the relationship -and archives its now-unowned exotic talent. Exotic talent creation and editing -are rejected by the shared Talent Registry so ownership cannot be bypassed. +`talent_tiers`, description template, and typed conditional rule together with +the weapon. Server validation rejects an exotic talent ID owned by another +weapon and rejects any shared Talent. Converting a weapon back to standard +clears the relationship and archives its now-unowned exotic Talent. The shared +Talent Registry rejects exotic Talent creation and editing so contributors +cannot bypass weapon ownership. # OS Protocol progression OS Protocols use dedicated normalized tables because their talent progression has six calculated states (base plus five upgrades) rather than the shared two-tier talent model. Each protocol owns one `os_protocol_primary_effects` -record and one `os_protocol_talents` record. Talent effects and reusable -triggers live in their respective join tables. +record and one `os_protocol_talents` record. Each protocol Talent owns its +mechanic program through the shared conditional-effect tables. -The three secondary attributes are normalized separately. One +Dedicated profile tables normalize the three secondary attributes. One `os_protocol_secondary_profiles` record exists for each configured quality + attribute-type combination, with its three fixed stat/value pairs in `os_protocol_secondary_effects`. Every matching protocol inherits that profile; @@ -275,16 +280,17 @@ single slot type and one or more compatible base weapon categories through and the slot to exist on the equipped weapon at its selected tier. Exotic Weapon Mods instead reference one `exotic_weapon_id`. They do not carry -regular category compatibility, are considered permanently equipped, and may -only be leveled. They do not use quality profiles, random primary rolls, -secondary pools, or regular Mod Effects. Their canonical contributions are -fixed attribute references with explicit level-1 and level-20 values in -`weapon_mod_exotic_attributes`; intermediate values interpolate linearly. +regular category compatibility. The builder treats them as permanent equipment +and lets the user change only their level. They do not use quality profiles, +random primary rolls, secondary pools, or regular Mod Effects. Their canonical +contributions are fixed attribute references with explicit level-1 and level-20 +values in `weapon_mod_exotic_attributes`; intermediate values use linear +interpolation. All regular Weapon Mods permit Firepower, Toughness, or Engineering as a builder-time primary choice. `weapon_mod_quality_values` stores the shared level-1 and level-20 primary roll endpoints for each quality; primary values -interpolate linearly from levels 1–20. +use linear interpolation from levels 1–20. `weapon_mod_secondary_options` is the shared pool from which a build selects two distinct secondary attributes. Every option owns a minimum and maximum at @@ -298,7 +304,7 @@ values in `weapon_mod_effect_quality_values`. Gear Mod definitions are standard-quality records only; there is no exotic Gear Mod branch. Every definition supplies primary roll ranges for Standard, Specialized, Superior, and High-end quality at Levels 1 and 20. Values between -those endpoints scale linearly. A build instance selects one primary attribute +those endpoints use linear interpolation. A build instance selects one primary attribute from Firepower, Toughness, Engineering, Health, or Armor. The two secondary slots draw distinct attributes from one shared option pool. @@ -306,8 +312,8 @@ Each available secondary attribute stores its own minimum and maximum for all four qualities. Gear tier determines capacity without per-item configuration: T0 gear has one Gear Mod slot; T1 and T2 gear have two. -Canonical data is stored in `gear_mods`, `gear_mod_quality_values`, -`gear_mod_secondary_options`, and `gear_mod_secondary_quality_values`. +The `gear_mods`, `gear_mod_quality_values`, `gear_mod_secondary_options`, and +`gear_mod_secondary_quality_values` tables store canonical data. Successful mutations use `gear_mod.created`, `gear_mod.updated`, `gear_mod.archived`, and `gear_mod.unarchived` audit actions. Contributor submissions use the `gearmods` proposal module and remain non-canonical until @@ -317,16 +323,16 @@ approved by Oversight. Skill Chip sets support Superior and High-end chip instances. Each set owns one 2-piece bonus, distinct primary and secondary stat-option pools, and one -3-piece talent with reusable trigger links and normalized effect rows. Primary -and secondary options reference active attributes that define Skill Chip -minimum and maximum roll values; a configured chip rolls one primary and two -distinct secondary stats from those separate pools. +3-piece Talent with a typed conditional rule. Primary and secondary options +reference active Attributes that define Skill Chip minimum and maximum roll +values. A configured chip rolls one primary and two distinct secondary stats +from those pools. -Every active Skill Chip definition belongs exclusively to one active -specialization ability through `skill_chip_sets.specialization_ability_id`. -An ability has capacity for exactly three active definitions. The save and -restore actions enforce that capacity, and the foreign key is required for -every row. A specialization focus is builder-ready only when each of its three +Each active Skill Chip definition belongs to one active specialization ability +through `skill_chip_sets.specialization_ability_id`. An ability has capacity for +three active definitions. The save and restore actions enforce that capacity, +and the schema requires the foreign key for each row. A specialization focus is +builder-ready only when each of its three assigned abilities has all three active Skill Chip definitions. Skill Chip mutations refresh readiness for focuses that reference both the previous and resulting ability. @@ -337,48 +343,176 @@ three, a configured minimum and maximum resolve as `minimum + (maximum − minimum) × h / 3`. Zero High-end chips therefore produce the minimum, while three produce the maximum. The editor stores endpoints and previews all four states, preventing duplicated tier data from diverging. -Skill Chip mutations, owned relationships, and safe icon metadata are captured -in the `game_data` audit stream. +The `game_data` audit stream captures Skill Chip mutations, owned relationships, +and safe icon metadata. -# Specialization combat effects and loadout state +# Mechanic programs and loadout state -`specialization_combat_effects` stores reusable effect identity and description -within one Specialization. A node rank attaches that identity through -`specialization_tree_rank_combat_effects` and supplies the trigger count, -trigger window, duration, stack limit, and stack gain. The first supported -lifecycle uses an enemy-hit counter that resets after a proc. Each stack gain -refreshes one shared duration, and expiry clears all stacks. +Mechanic programs belong to the record whose behavior they describe. One +`conditional_effect_rules` row owns the program for one standard or exotic +Talent, OS Protocol Talent, Skill Chip Talent, or Specialization tree rank. The +`source_type` and `source_id` pair identifies that owner. No standalone Trigger +record or shared Trigger relationship participates in execution. -`specialization_tree_rank_combat_modifiers` maps each attachment to one or more -Attributes with a value per stack. This split lets ranks share effect language -while changing their trigger and values. Specialization mutations capture the -attachment and modifier sets in the `game_data` audit event. Contributors can -submit effect definitions and node attachments through the existing -Specialization proposal workflow. +Each source supplies a fixed value-variant contract: -Loadout schema version 2 stores one focus, the selected rank for each tree node, -nine Skill Chips, conditional-effect modes, and encounter assumptions. Each of -the three focus abilities owns three chip positions. Runtime readers accept -only the exact version 2 structure. Local drafts, saved builds, shared builds, -and calibration snapshots reject older or partial structures instead of -repairing them. Database migration removes obsolete snapshots after the -required environment backup. +- Standard and exotic Talents require Tier I and Tier II values. +- OS Protocol Talents store a base value. An outcome may use an expression that + reads `equipment.protocolTalentUpgrades`, from zero through five, so selected + values can scale to twice the base at upgrade five. +- Skill Chip Talents store minimum and maximum values for quality composition + interpolation. +- Specialization tree-rank effects store the rank's base value. + +The rule row stores the script version, source-defined scenario-input controls, +configuration state, and one of three simulation-readiness states: + +- `ready`: the runtime has enough typed data to calculate the mechanic. +- `requires_assumptions`: the mechanic needs one or more scenario inputs before + the runtime can include it. +- `captured_only`: ISAC preserves the authored mechanic, but the runtime cannot + calculate it. + +The code-defined `MECHANIC_EVENT_REGISTRY` is the event authority. Its 19 +normalized events are `shot_fired`, `weapon_hit`, `skill_deployed`, +`skill_hit`, `agent_hit`, `health_damage_taken`, `damage_dealt`, +`enemy_killed`, `assist`, `healing_applied`, `extra_health_granted`, +`movement_snapshot`, `proximity_snapshot`, `firing_started`, `firing_stopped`, +`reload_started`, `reload_completed`, `weapon_swapped`, and `tick`. Event +details such as target, distance, critical state, source, movement, and nearby +counts travel as typed event values. + +The optional Action Family catalog provides authoring recipes for common +mechanics. Applying a recipe copies its typed scenario inputs, state pools, and +clauses into the source-owned program. Those branches become editable +authoritative data; the recipe key remains provenance, not a runtime +dependency. Adding another recipe appends branches. Replacing a program with a +recipe requires confirmation because replacement discards the current branch +graph. + +The program graph uses three child tables: + +- `conditional_effect_state_pools` defines agent- or target-scoped flags, + counts, stacks, durations, scalar values, marks, and resources. Each pool + owns its bounds, expiry policy, refresh policy, and best-case value. +- `conditional_effect_clauses` listens for one of the 19 events. A clause owns + a typed predicate, reducer, optional cooldown, optional weight expression, + state mutations, and optional recipe provenance. Reducers convert event + streams into counts, distinct or consecutive matches, sliding windows, + durations, intervals, or derived values. Mutations can set, add, consume, + clear, derive, or transfer state. +- `conditional_effect_outcomes` applies Attribute modifiers, direct damage, + restoration, resource costs, or markers. Each outcome names its target scope, + value basis, optional resource or Attribute, combat timing, optional state + band, and phase: `on_event`, `after_event`, `on_enter`, `on_exit`, or + `while_active`. One typed value or expression exists for every variant + required by the owning source. The common value models are static values, + event-field values, reducer output, source-defined scenario values, and OS + Protocol upgrade scaling. + +The generic runtime in `src/lib/calculations/mechanic.ts` processes combat events +in timestamp order. Predicates decide whether an event enters a clause. Reducers +convert event streams into activations or derived values. The runtime applies +state mutations, enforces pool bounds and expiry, emits outcomes in phase order, +and records telemetry for each activation. + +Periodic behavior requires an interval in the reducer. The runtime does not +infer a one-second cadence from missing data. A mechanic with an unknown cadence +uses `requires_assumptions`, identifies the missing input, and stays out of the +calculation until an operator or builder scenario supplies it. + +The runtime derives a read-only pseudo-language listing from the typed program. +ISAC and the builder use that listing to explain events, predicates, reducers, +state changes, phases, and formulas. No route accepts the generated text as +input, and no server process executes it. Operators edit structured fields; the +formatter regenerates the listing after each change. + +Source authors define the builder controls required by their mechanic as typed +toggle, slider, or numeric scenario inputs. Each input records its label, +guidance, bounds when applicable, default value, and best-case value. These +controls appear only in advanced scenario configuration. Maximum mode supplies +best-case values. Custom mode uses the values saved with the loadout. A +mechanic with an undefined required assumption remains excluded and reports +`REQUIRES_ASSUMPTIONS`. + +Loadout schema version 4 stores one focus, selected tree ranks, nine Skill +Chips, a unified conditional-effect state map, encounter assumptions, +per-ability activation controls, and `calculationMode`. Runtime readers accept +the exact version 4 structure. A backed-up prototype migration removes drafts, +saved builds, and calibration snapshots that use incompatible payloads. + +## Calculation modes + +The builder offers `maximum` and `custom` modes. + +- Maximum mode supplies each scenario input's best-case value, selects peak + conditional-effect state, and runs selected abilities on cooldown against + their configured target caps. The interface hides granular assumptions in + this mode. +- Custom mode uses the agent's encounter inputs, effect states, activation + rates, target counts, and other supported assumptions. A missing required + input produces `REQUIRES_ASSUMPTIONS` rather than a fabricated value. + +Saved and shared loadouts preserve the selected mode. Calculation results label +the mode so readers can distinguish a best-case ceiling from a scenario-based +estimate. + +## Specialization ability attacks + +`specialization_ability_attacks` stores intrinsic base damage, Blast, Pierce, or +Shredding damage, direct or periodic delivery, hit or tick counts, cast time, +target cap, and charge behavior. `skill_attribute_calculation_profiles` binds +Attributes to the versioned Skill damage and cadence stages. Cooldown Recovery +changes charge and activation readiness. Duration changes the number of +periodic ticks. + +Periodic delivery waits one configured tick interval before its first tick and +excludes ticks at or after the encounter boundary. The Skill engine reevaluates +active modifier windows at each delivered hit or tick. A missing tick interval +marks the attack as requiring configuration; the engine does not supply a +default cadence. # Versioned combat calculations -`calculation_revisions` supplies one active rule set. Draft revisions can -change, while active and retired revisions stay immutable. +`calculation_revisions` supplies one active rule set. Operators can change draft +revisions, while active and retired revisions stay immutable. `calculation_stages` stores the ordered typed pipeline. The base-damage stage holds the first position, and the firing-cycle stage holds the last. -`attribute_calculation_profiles` maps an Attribute to a stage with its weight, -optional caps, curve, and weapon-category scope. +`attribute_calculation_profiles` maps an Attribute to a weapon stage with its +weight, optional caps, curve, and weapon-category scope. The shared combat engine consumes weapon facts, accumulated Attributes, -calculation profiles, and conditional effects. It produces four hit values, -burst DPS, reload-aware sustained DPS, encounter DPS, and stack telemetry. -Encounter mode walks the expected shot timeline from RPM, magazine size, -reload time, hit rate, firing activity, and encounter duration. Peak mode -applies maximum stacks, and Off mode excludes the effect. +calculation profiles, and mechanic outcomes. It produces four hit values, burst +DPS, reload-aware sustained DPS, encounter DPS, and stack telemetry. Custom mode +walks the expected shot timeline from RPM, magazine size, reload time, hit rate, +configured burst size, pauses between bursts, and encounter duration. It +resolves the active Attribute state at each landed shot before calculating that +shot's expected damage. + +The builder scales a weapon definition's level-1 damage range to the equipped +item level, then the agent selects one damage value inside that valid range. +Combat receives `equipped_weapon.damage` as its base damage and consumes that +number once. The combat pipeline has no `level_scaled_damage` value and must not +apply item-level scaling a second time. + +The generated calculation listing names each typed pipeline stage and its bound +Attributes. The listing explains the active revision with statements such as +`TAKE equipped_weapon.damage AS base_damage`, `ADD`, `APPLY`, `WEIGHT`, and +`RETURN`. The typed stages and profiles remain authoritative. The application +neither parses nor executes the listing. + +The Headshot Kill Share assumption marks the matching portion of generated kill +events for headshot-kill triggers. Rate of Fire, Magazine Size, and Reload Speed +bindings change the firing-cycle calculation. Maximum mode applies maximum +state. Custom mode can set an effect to Peak, Encounter, or Off. + +The Skill engine uses a dedicated intrinsic ability-damage model. It supports +direct and periodic delivery, critical and multi-shot expectation, target caps, +per-ability activation rates, and charge recovery. +Skills do not headshot. Dynamic Talent and tree-effect windows apply to each +Skill hit at its delivery time. Encounter-triggered Skill modifiers use the +primary weapon timeline until the builder supports a combined weapon-rotation +model. The engine returns `INSUFFICIENT_DATA` with missing requirement keys when a calculation lacks required inputs or an Attribute binding. Callers must render @@ -389,35 +523,60 @@ active revision for each request. hit values, timing values, optional encounter DPS, and the model prediction. Ready cases require the four hit values plus magazine and reload times. The assisted suggestion action compares ready observations with their predictions -and creates a draft revision for an operator to review. +and creates a draft revision for operator review. -Rule activation requires `calculation.publish` and an explicit confirmation. +Rule activation requires `calculation.publish` and a confirmation. ISAC records draft creation, draft edits, publication, calibration changes, and assisted suggestions under the `calculation_configuration` audit category. ISAC data export and environment synchronization include calculation rules, -calibration cases, and Specialization combat-effect tables in dependency order. +typed mechanic programs, ability attacks, calibration cases, and their +relationships in dependency order. + +## OS Protocol upgrade expressions and health timing + +OS Protocol talents expose zero through five upgrade states. Value expressions +reference `equipment.protocolTalentUpgrades` and use the linear multiplier +`1 + upgrades / 5`. Base values resolve at upgrade zero, and scalable values +reach twice their base at upgrade five. Each outcome chooses whether to use this +multiplier, so a fixed resource cost can remain unchanged while restoration and +damage increase. + +Happy Survival uses three clauses: + +1. Its proximity clause records the current enemy count within 10 meters. +2. Its periodic clause restores + `min(enemyCount × 0.5%, 2.5%) × (1 + upgrades / 5)` of maximum Health. The + reducer reads the configured interval from an explicit scenario input. The + runtime caps applied restoration at missing Health. +3. Its `shot_fired` clause consumes 2% of maximum Health, then deals direct + damage equal to `0.6% × (1 + upgrades / 5)` of current Health. The + `resource_cost` outcome runs first. The damage outcome uses the remaining + current Health after that cost, rather than the pre-event Health value. + +At upgrade five, the restoration becomes 1% of maximum Health per nearby enemy +up to 5%, and the shot damage becomes 1.2% of current Health. The 2% Health cost +does not scale. # Game-data icon storage Talents, brand sets, OS Protocols, and Skill Chips reference uploaded artwork through the polymorphic `game_data_icons` metadata table. The shared server pipeline accepts transparent SVG, PNG, WebP, and AVIF sources, rejects active -or externally referenced SVG content, and uses Sharp’s native worker pool to +SVG content and external references, and uses Sharp’s native worker pool to rasterize and normalize each accepted icon to a transparent 256 × 256 WebP. -Normalized files live beneath the ignored `data/game-icons/` directory and are -served by the validated `/icons/game-data/[type]/[id]` endpoint with immutable -cache headers and MIME sniffing disabled. ISAC save actions process files +Normalized files live beneath the ignored `data/game-icons/` directory. The +validated `/icons/game-data/[type]/[id]` endpoint serves them with immutable +cache headers and disables MIME sniffing. ISAC save actions process files before mutation, apply upload or removal after record validation, and include safe icon metadata, rather than image bytes, in audit snapshots. Icon metadata is optional. Canonical records and contributor proposals may be -created without an upload, allowing values and artwork to be contributed in -separate dependency-safe changes. Missing artwork is represented at render -time by the static `/missing-item-icon.svg` asset through the shared -`MISSING_ITEM_ICON_URL` constant and `ItemIcon` component. The placeholder does -not enter `game_data_icons`. A later edit or proposal can attach the real icon -without recreating the canonical item. +created without an upload, which lets contributors submit values and artwork in +separate dependency-safe changes. The shared `MISSING_ITEM_ICON_URL` constant +and `ItemIcon` component render `/missing-item-icon.svg` for missing artwork. +The placeholder does not enter `game_data_icons`. A later edit or proposal can +attach the real icon without recreating the canonical item. `source_entity_type` and `source_entity_id` link each `game_data_icons` row to a `managed_images` record. Both records share one storage key and one WebP file. @@ -433,14 +592,14 @@ keeps the library metadata and one copy of each file. # Oversight notification dispatch -New Data Change and Avatar Oversight review items are routed only to agents who -currently hold `data.approve` or `avatars.review` respectively. Each reviewer -chooses Immediate, 15-minute, one-hour, or daily delivery independently for -each authorized queue. Immediate alerts are created during submission; -consolidated alerts are persisted in `oversight_notification_queue`. +The notification router sends Data Change review items to agents who hold +`data.approve` and Avatar Oversight review items to agents who hold +`avatars.review`. Each reviewer chooses Immediate, 15-minute, one-hour, or +daily delivery for each authorized queue. Submission creates Immediate alerts; +`oversight_notification_queue` stores consolidated alerts. -Production must run the idempotent dispatcher once per minute from exactly one -scheduler instance: +Production must run the idempotent dispatcher once per minute from one scheduler +instance: ```text * * * * * cd /path/to/shd.build && npm run notifications:oversight:production @@ -449,27 +608,27 @@ scheduler instance: The dispatcher groups due rows by reviewer, queue, cadence, and delivery window. Daily rows use the next 10:00 AM wall-clock boundary in `America/New_York`, including daylight-saving transitions. Dispatched rows -retain `delivered_at` evidence and are excluded from later selection. Existing +retain `delivered_at` evidence, and later queries exclude them. Existing ISAC notification delivery preferences determine optional browser and email -fan-out after the consolidated inbox message is created. +fan-out after the dispatcher creates the consolidated inbox message. ## Automated ISAC system messages Automated message definitions live in `automated_system_messages` and are managed through ISAC → System Configuration → Automated Messages by agents with -`system.notifications.manage`. Edits are recorded under the +`system.notifications.manage`. The audit log records edits under the `system_configuration` audit category. Definitions identify a lifecycle event, enabled state, delay in minutes, message content, primary destination, and structured action callouts. -`account.created` is emitted by password, magic-link, and Discord registration. +Password, magic-link, and Discord registration emit `account.created`. The delivery table has a unique message/user/event/event-ID constraint, so the same account creation cannot deliver the Welcome Agent message more than once. The default welcome introduces News, Database, Builds, Guides, Builder, ISAC Contributor Mode, and the SHD Discord community. -Immediate definitions are delivered in the registration request. Definitions -with a delay are persisted in `automated_system_message_deliveries`. Production +The registration request delivers Immediate definitions. +`automated_system_message_deliveries` stores definitions with a delay. Production must run the delayed dispatcher once per minute: ```text diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index 674d2cb..5b9d05e 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -35,20 +35,44 @@ const isProtocolSelection = (value) => && typeof value.itemId === 'string' && isFiniteNumber(value.level) && isFiniteNumber(value.talentUpgrades); -const combatProfileKeys = ['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity']; +const numericCombatProfileKeys = [ + 'encounterSeconds', 'hitRate', 'headshotRate', 'shotsPerBurst', 'burstPauseSeconds', + 'sameTargetSeconds', 'distinctTargetsPerMinute', 'killsPerMinute', 'headshotKillRate', 'damageTakenPerMinute', + 'swapsPerMinute', 'targetDistanceMeters', 'healthPercent' +]; +const combatProfileKeys = [...numericCombatProfileKeys, 'extraHealthActive']; const isCombatProfile = (value) => - hasExactKeys(value, combatProfileKeys) && combatProfileKeys.every((key) => isFiniteNumber(value[key])); + hasExactKeys(value, combatProfileKeys) + && numericCombatProfileKeys.every((key) => isFiniteNumber(value[key])) + && typeof value.extraHealthActive === 'boolean'; const isEffectStates = (value) => isRecord(value) && Object.values(value).every((entry) => { - if (!hasExactKeys(entry, entry?.overrides === undefined ? ['mode'] : ['mode', 'overrides']) + if (!isRecord(entry) + || Object.keys(entry).some((key) => !['mode', 'overrides', 'inputs'].includes(key)) || !['off', 'peak', 'encounter'].includes(String(entry.mode))) return false; + if (entry.inputs !== undefined && ( + !isRecord(entry.inputs) + || Object.values(entry.inputs).some((input) => + typeof input !== 'boolean' && !isFiniteNumber(input) + ) + )) return false; return entry.overrides === undefined || ( isRecord(entry.overrides) && Object.entries(entry.overrides).every(([key, override]) => - combatProfileKeys.includes(key) && isFiniteNumber(override) + key === 'extraHealthActive' + ? typeof override === 'boolean' + : numericCombatProfileKeys.includes(key) && isFiniteNumber(override) ) ); }); +const isSkillActivations = (value) => + isRecord(value) && Object.values(value).every((entry) => + isRecord(entry) + && Object.keys(entry).every((key) => ['mode', 'usesPerMinute', 'targets'].includes(key)) + && ['off', 'on_cooldown', 'custom_per_minute'].includes(String(entry.mode)) + && isFiniteNumber(entry.targets) + && (entry.usesPerMinute === undefined || isFiniteNumber(entry.usesPerMinute)) + ); const isCurrentLoadoutSnapshot = (raw) => { let value; try { @@ -56,9 +80,13 @@ const isCurrentLoadoutSnapshot = (raw) => { } catch { return false; } - return hasExactKeys(value, ['version', 'buildName', 'gear', 'weapons', 'chips', 'protocol', 'activeTalents', 'specialization']) - && value.version === 2 + return hasExactKeys(value, [ + 'version', 'buildName', 'gear', 'weapons', 'chips', 'protocol', + 'conditionalEffects', 'combatProfile', 'calculationMode', 'skillActivations', 'specialization' + ]) + && value.version === 4 && typeof value.buildName === 'string' + && ['maximum', 'custom'].includes(value.calculationMode) && hasExactKeys(value.gear, gearSlots) && gearSlots.every((slot) => isGearSelection(value.gear[slot])) && Array.isArray(value.weapons) @@ -68,14 +96,13 @@ const isCurrentLoadoutSnapshot = (raw) => { && value.chips.length === 9 && value.chips.every(isChipSelection) && isProtocolSelection(value.protocol) - && Array.isArray(value.activeTalents) - && value.activeTalents.every((talent) => typeof talent === 'string') - && hasExactKeys(value.specialization, ['focusId', 'nodeRanks', 'effectStates', 'combatProfile']) + && isEffectStates(value.conditionalEffects) + && isCombatProfile(value.combatProfile) + && isSkillActivations(value.skillActivations) + && hasExactKeys(value.specialization, ['focusId', 'nodeRanks']) && typeof value.specialization.focusId === 'string' && isRecord(value.specialization.nodeRanks) - && Object.values(value.specialization.nodeRanks).every(isFiniteNumber) - && isEffectStates(value.specialization.effectStates) - && isCombatProfile(value.specialization.combatProfile); + && Object.values(value.specialization.nodeRanks).every(isFiniteNumber); }; const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'); @@ -114,9 +141,877 @@ for (const statement of statements) { // This index is deferred until the additive legacy-column migration below. if (statement.startsWith('CREATE INDEX IF NOT EXISTS skill_chip_sets_ability_idx')) continue; if (statement.startsWith('CREATE UNIQUE INDEX IF NOT EXISTS managed_images_source_entity_idx')) continue; + if (statement.startsWith('CREATE INDEX IF NOT EXISTS conditional_effect_clauses_event_idx')) continue; await database.exec(statement); } +const skillProfileDefinition = await (await database.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='skill_attribute_calculation_profiles'" +)).get(); +if (typeof skillProfileDefinition?.sql === 'string' && !skillProfileDefinition.sql.includes("'flat_damage'")) { + console.log('Adding flat damage to versioned Skill calculation profiles.'); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec('ALTER TABLE skill_attribute_calculation_profiles RENAME TO skill_attribute_calculation_profiles_previous'); + await database.exec(`CREATE TABLE skill_attribute_calculation_profiles ( + id TEXT PRIMARY KEY, + revision_id TEXT NOT NULL REFERENCES calculation_revisions(id) ON DELETE CASCADE, + stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id), + stage_key TEXT NOT NULL CHECK(stage_key IN ( + 'flat_damage','engineering','skill_damage','critical_chance','critical_damage', + 'multishot_chance','multishot_damage','final_damage', + 'cooldown_recovery','duration' + )), + operator TEXT NOT NULL CHECK(operator IN ('add','multiply')), + weight REAL NOT NULL DEFAULT 1, + soft_cap REAL, + hard_cap REAL, + curve_type TEXT NOT NULL DEFAULT 'none' CHECK(curve_type IN ('none','power','smoothstep')), + curve_parameter REAL, + UNIQUE(revision_id,stat_attribute_id,stage_key), + CHECK(soft_cap IS NULL OR soft_cap>=0), + CHECK(hard_cap IS NULL OR hard_cap>=0), + CHECK(soft_cap IS NULL OR hard_cap IS NULL OR hard_cap>=soft_cap) + )`); + await database.exec(`INSERT INTO skill_attribute_calculation_profiles + SELECT * FROM skill_attribute_calculation_profiles_previous`); + await database.exec('DROP TABLE skill_attribute_calculation_profiles_previous'); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } +} + +const triggerColumns = await (await database.prepare("PRAGMA table_info('talent_triggers')")).all(); +if (triggerColumns.some((column) => column.name === 'supports_uptime')) { + console.log('Replacing free-form trigger uptime fields with typed trigger actions.'); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec('DROP TABLE IF EXISTS talent_triggers_typed'); + await database.exec(`CREATE TABLE talent_triggers_typed ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL, + action_key TEXT, + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`); + await database.exec(`INSERT INTO talent_triggers_typed(id,name,description,action_key,active,created_at,updated_at) + SELECT id,name,description,NULL,active,created_at,updated_at FROM talent_triggers`); + await database.exec('DROP TABLE talent_triggers'); + await database.exec('ALTER TABLE talent_triggers_typed RENAME TO talent_triggers'); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } + await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS talent_triggers_name_nocase_idx ON talent_triggers(name COLLATE NOCASE)'); +} + +const conditionalRuleColumns = await (await database.prepare( + "PRAGMA table_info('conditional_effect_rules')" +)).all(); +if (conditionalRuleColumns.some((column) => column.name === 'trigger_id')) { + console.log('Migrating conditional effects to multi-clause mechanic programs.'); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec('DROP VIEW IF EXISTS conditional_effect_rule_primary'); + await database.exec('DROP TABLE IF EXISTS conditional_effect_clauses'); + await database.exec('DROP TABLE IF EXISTS conditional_effect_state_pools'); + await database.exec('ALTER TABLE conditional_effect_outcomes RENAME TO conditional_effect_outcomes_previous'); + await database.exec('ALTER TABLE conditional_effect_rules RENAME TO conditional_effect_rules_previous'); + await database.exec(`CREATE TABLE conditional_effect_rules ( + id TEXT PRIMARY KEY, + source_type TEXT NOT NULL CHECK(source_type IN ( + 'talent','os_protocol_talent','skill_chip_talent','specialization_tree_rank' + )), + source_id TEXT NOT NULL, + configured INTEGER NOT NULL DEFAULT 0 CHECK(configured IN (0,1)), + script_version INTEGER NOT NULL DEFAULT 1 CHECK(script_version>0), + simulation_readiness TEXT NOT NULL DEFAULT 'ready' CHECK(simulation_readiness IN ( + 'ready','requires_assumptions','captured_only' + )), + scenario_inputs_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(source_type,source_id) + )`); + await database.exec(`CREATE TABLE conditional_effect_state_pools ( + id TEXT PRIMARY KEY, + rule_id TEXT NOT NULL REFERENCES conditional_effect_rules(id) ON DELETE CASCADE, + state_key TEXT NOT NULL, + label TEXT NOT NULL, + state_kind TEXT NOT NULL DEFAULT 'stack' CHECK(state_kind IN ( + 'flag','count','stack','duration','scalar','target_mark','resource' + )), + state_scope TEXT NOT NULL DEFAULT 'agent' CHECK(state_scope IN ('agent','target')), + initial_value REAL NOT NULL DEFAULT 0, + minimum_value REAL, + maximum_value REAL NOT NULL DEFAULT 1 CHECK(maximum_value>0), + best_case_value REAL, + duration_seconds REAL CHECK(duration_seconds IS NULL OR duration_seconds>0), + expiry_policy TEXT NOT NULL DEFAULT 'clear_all' CHECK(expiry_policy IN ('clear_all','decrement_one','none')), + refresh_policy TEXT NOT NULL DEFAULT 'refresh' CHECK(refresh_policy IN ('refresh','extend','ignore')), + parameters_json TEXT NOT NULL DEFAULT '{}', + sort_order INTEGER NOT NULL CHECK(sort_order>=1), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(rule_id,state_key), + UNIQUE(rule_id,sort_order), + CHECK(minimum_value IS NULL OR maximum_value>=minimum_value), + CHECK(best_case_value IS NULL OR minimum_value IS NULL OR best_case_value>=minimum_value), + CHECK(best_case_value IS NULL OR best_case_value<=maximum_value) + )`); + await database.exec(`CREATE TABLE conditional_effect_clauses ( + id TEXT PRIMARY KEY, + rule_id TEXT NOT NULL REFERENCES conditional_effect_rules(id) ON DELETE CASCADE, + clause_key TEXT NOT NULL, + label TEXT NOT NULL, + trigger_id TEXT NOT NULL REFERENCES talent_triggers(id), + parameters_json TEXT NOT NULL DEFAULT '{}', + conditions_json TEXT NOT NULL DEFAULT '{"kind":"true"}', + reducer_json TEXT NOT NULL DEFAULT '{"kind":"none"}', + emission_kind TEXT NOT NULL DEFAULT 'each_match' CHECK(emission_kind IN ( + 'each_match','threshold_crossed','while_true','interval_elapsed','value_changed' + )), + state_pool_id TEXT REFERENCES conditional_effect_state_pools(id) ON DELETE SET NULL, + mutations_json TEXT NOT NULL DEFAULT '[]', + cooldown_seconds REAL CHECK(cooldown_seconds IS NULL OR cooldown_seconds>0), + output_branch TEXT NOT NULL DEFAULT 'active', + sort_order INTEGER NOT NULL CHECK(sort_order>=1), + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(rule_id,clause_key), + UNIQUE(rule_id,sort_order) + )`); + await database.exec(`CREATE TABLE conditional_effect_outcomes ( + id TEXT PRIMARY KEY, + rule_id TEXT NOT NULL REFERENCES conditional_effect_rules(id) ON DELETE CASCADE, + clause_id TEXT REFERENCES conditional_effect_clauses(id) ON DELETE CASCADE, + state_pool_id TEXT REFERENCES conditional_effect_state_pools(id) ON DELETE SET NULL, + effect_key TEXT NOT NULL, + variant_key TEXT NOT NULL DEFAULT 'base', + outcome_key TEXT NOT NULL DEFAULT 'active', + application_kind TEXT NOT NULL DEFAULT 'active_modifier' CHECK(application_kind IN ( + 'active_modifier','direct_damage','restore_health','restore_armor','restore_ammo','resource_cost' + )), + effect_scope TEXT NOT NULL DEFAULT 'self' CHECK(effect_scope IN ( + 'self','equipped_weapon','target','allies','self_and_allies','assigned_ability','all_skills' + )), + stat_attribute_id TEXT REFERENCES stat_attributes(id), + value REAL NOT NULL, + value_expression_json TEXT, + value_basis TEXT NOT NULL DEFAULT 'flat' CHECK(value_basis IN ( + 'flat','max_health_percent','current_health_percent','weapon_base_percent' + )), + value_scaling TEXT NOT NULL DEFAULT 'per_state_unit' CHECK(value_scaling IN ('flat','per_state_unit')), + unit TEXT, + damage_basis TEXT CHECK(damage_basis IS NULL OR damage_basis IN ('flat','weapon_base_percent')), + application_phase TEXT NOT NULL DEFAULT 'while_active' CHECK(application_phase IN ( + 'while_active','on_event','after_event','on_enter','on_exit' + )), + min_state_value REAL, + max_state_value REAL, + description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1), + sort_order INTEGER NOT NULL CHECK(sort_order>=1), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(rule_id,variant_key,outcome_key,sort_order), + CHECK(min_state_value IS NULL OR min_state_value>=0), + CHECK(max_state_value IS NULL OR max_state_value>=0), + CHECK(min_state_value IS NULL OR max_state_value IS NULL OR max_state_value>=min_state_value), + CHECK( + (application_kind='active_modifier' AND stat_attribute_id IS NOT NULL AND damage_basis IS NULL) + OR (application_kind='direct_damage' AND damage_basis IS NOT NULL) + OR application_kind IN ('restore_health','restore_armor','restore_ammo','resource_cost') + ) + )`); + await database.exec(`INSERT INTO conditional_effect_rules + (id,source_type,source_id,configured,script_version,simulation_readiness, + scenario_inputs_json,created_at,updated_at) + SELECT id,source_type,source_id,configured,1,'ready','[]',created_at,updated_at + FROM conditional_effect_rules_previous`); + await database.exec(`INSERT INTO conditional_effect_state_pools + (id,rule_id,state_key,label,state_kind,state_scope,initial_value,minimum_value, + maximum_value,best_case_value,duration_seconds,expiry_policy,refresh_policy, + parameters_json,sort_order,created_at,updated_at) + SELECT id||':effect',id,'effect','Effect','stack','agent',0,0,max_stacks,max_stacks, + duration_seconds,'clear_all','refresh','{}',1,created_at,updated_at + FROM conditional_effect_rules_previous`); + await database.exec(`INSERT INTO conditional_effect_clauses + (id,rule_id,clause_key,label,trigger_id,parameters_json,conditions_json,reducer_json, + emission_kind,state_pool_id,mutations_json,cooldown_seconds,output_branch,sort_order, + active,created_at,updated_at) + SELECT id||':primary',id,'primary','Primary',trigger_id,parameters_json,'{"kind":"true"}', + '{"kind":"none"}','each_match',id||':effect', + '[{"kind":"add","pool":"effect","value":'||stacks_per_trigger||'}]', + cooldown_seconds,'active',1,1,created_at,updated_at + FROM conditional_effect_rules_previous + WHERE trigger_id IS NOT NULL`); + await database.exec(`INSERT INTO conditional_effect_outcomes + (id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key,application_kind, + effect_scope,stat_attribute_id,value,value_expression_json,value_basis,value_scaling, + unit,damage_basis,application_phase,min_state_value,max_state_value,description_slot, + sort_order,created_at,updated_at) + SELECT outcome.id,outcome.rule_id, + CASE WHEN rule.trigger_id IS NULL THEN NULL ELSE rule.id||':primary' END, + rule.id||':effect',outcome.outcome_key||'-effect-'||outcome.sort_order, + outcome.variant_key,outcome.outcome_key,outcome.application_kind, + outcome.effect_scope,outcome.stat_attribute_id,outcome.value,NULL, + CASE WHEN outcome.damage_basis='weapon_base_percent' THEN 'weapon_base_percent' ELSE 'flat' END, + 'per_state_unit',outcome.unit,outcome.damage_basis, + CASE WHEN outcome.application_kind='active_modifier' THEN 'while_active' ELSE 'after_event' END, + CASE WHEN outcome.application_kind='active_modifier' THEN 1 ELSE NULL END, + NULL,CASE WHEN outcome.application_kind='active_modifier' THEN outcome.sort_order ELSE NULL END, + outcome.sort_order,outcome.created_at,outcome.created_at + FROM conditional_effect_outcomes_previous outcome + JOIN conditional_effect_rules_previous rule ON rule.id=outcome.rule_id`); + await database.exec('DROP TABLE conditional_effect_outcomes_previous'); + await database.exec('DROP TABLE conditional_effect_rules_previous'); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } + await database.exec(`CREATE INDEX IF NOT EXISTS conditional_effect_state_pools_rule_idx + ON conditional_effect_state_pools(rule_id,sort_order)`); + await database.exec(`CREATE INDEX IF NOT EXISTS conditional_effect_clauses_trigger_idx + ON conditional_effect_clauses(trigger_id,rule_id)`); + await database.exec(`CREATE INDEX IF NOT EXISTS conditional_effect_outcomes_rule_idx + ON conditional_effect_outcomes(rule_id,variant_key,outcome_key,sort_order)`); + await database.exec(`CREATE VIEW IF NOT EXISTS conditional_effect_rule_primary AS + SELECT + rule.*, + clause.id AS clause_id, + clause.trigger_id, + clause.parameters_json, + clause.cooldown_seconds, + pool.id AS state_pool_id, + pool.duration_seconds, + pool.maximum_value AS max_stacks, + COALESCE(CAST(json_extract(clause.mutations_json,'$[0].value') AS REAL),1) AS stacks_per_trigger + FROM conditional_effect_rules rule + LEFT JOIN conditional_effect_clauses clause + ON clause.rule_id=rule.id AND clause.sort_order=1 + LEFT JOIN conditional_effect_state_pools pool + ON pool.id=clause.state_pool_id`); +} + +const legacyTriggerTable = await (await database.prepare( + "SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name='talent_triggers'" +)).get(); +if (legacyTriggerTable) { +const nowForConditionalMigration = new Date().toISOString(); +await (await database.prepare(`INSERT OR IGNORE INTO talent_triggers + (id,name,description,action_key,active,created_at,updated_at) + VALUES(?,?,?,?,1,?,?)`)).run( + 'trigger-same-target-hits-window', + 'Same target hits within a time window', + 'Activates after the configured number of hits land on the same target within the configured window.', + 'same_target_hits_within_window', + nowForConditionalMigration, + nowForConditionalMigration +); +await database.exec(`INSERT OR IGNORE INTO conditional_effect_rules + (id,source_type,source_id,configured,created_at,updated_at) + SELECT 'conditional-talent-'||talent.id,'talent',talent.id,0, + talent.created_at,talent.updated_at + FROM talents talent`); +await database.exec(`INSERT OR IGNORE INTO conditional_effect_rules + (id,source_type,source_id,configured,created_at,updated_at) + SELECT 'conditional-protocol-'||talent.id,'os_protocol_talent',talent.id,0, + talent.created_at,talent.updated_at + FROM os_protocol_talents talent`); +await database.exec(`INSERT OR IGNORE INTO conditional_effect_rules + (id,source_type,source_id,configured,created_at,updated_at) + SELECT 'conditional-chip-'||talent.id,'skill_chip_talent',talent.id,0, + talent.created_at,talent.updated_at + FROM skill_chip_talents talent`); +await (await database.prepare(`INSERT OR IGNORE INTO conditional_effect_rules + (id,source_type,source_id,configured,created_at,updated_at) + SELECT 'conditional-specialization-'||attachment.id,'specialization_tree_rank',attachment.id,1,?,? + FROM specialization_tree_rank_combat_effects attachment`)).run(nowForConditionalMigration, nowForConditionalMigration); +await database.exec(`INSERT OR IGNORE INTO conditional_effect_state_pools + (id,rule_id,state_key,label,state_kind,state_scope,initial_value,minimum_value, + maximum_value,best_case_value,duration_seconds,expiry_policy,refresh_policy, + parameters_json,sort_order,created_at,updated_at) + SELECT rule.id||':effect',rule.id,'effect','Effect','stack','agent',0,0, + COALESCE(talent.max_stacks,1),COALESCE(talent.max_stacks,1), + talent.duration_seconds,'clear_all','refresh','{}',1,talent.created_at,talent.updated_at + FROM conditional_effect_rules rule + JOIN talents talent ON rule.source_type='talent' AND rule.source_id=talent.id`); +await database.exec(`INSERT OR IGNORE INTO conditional_effect_state_pools + (id,rule_id,state_key,label,state_kind,state_scope,initial_value,minimum_value, + maximum_value,best_case_value,duration_seconds,expiry_policy,refresh_policy, + parameters_json,sort_order,created_at,updated_at) + SELECT rule.id||':effect',rule.id,'effect','Effect','stack','agent',0,0,1,1,NULL, + 'clear_all','refresh','{}',1,talent.created_at,talent.updated_at + FROM conditional_effect_rules rule + JOIN os_protocol_talents talent ON rule.source_type='os_protocol_talent' AND rule.source_id=talent.id`); +await database.exec(`INSERT OR IGNORE INTO conditional_effect_state_pools + (id,rule_id,state_key,label,state_kind,state_scope,initial_value,minimum_value, + maximum_value,best_case_value,duration_seconds,expiry_policy,refresh_policy, + parameters_json,sort_order,created_at,updated_at) + SELECT rule.id||':effect',rule.id,'effect','Effect','stack','agent',0,0,1,1,NULL, + 'clear_all','refresh','{}',1,talent.created_at,talent.updated_at + FROM conditional_effect_rules rule + JOIN skill_chip_talents talent ON rule.source_type='skill_chip_talent' AND rule.source_id=talent.id`); +await (await database.prepare(`INSERT OR IGNORE INTO conditional_effect_state_pools + (id,rule_id,state_key,label,state_kind,state_scope,initial_value,minimum_value, + maximum_value,best_case_value,duration_seconds,expiry_policy,refresh_policy, + parameters_json,sort_order,created_at,updated_at) + SELECT rule.id||':effect',rule.id,'effect','Effect','stack','agent',0,0, + attachment.max_stacks,attachment.max_stacks,attachment.duration_seconds, + 'clear_all','refresh','{}',1,?,? + FROM conditional_effect_rules rule + JOIN specialization_tree_rank_combat_effects attachment + ON rule.source_type='specialization_tree_rank' AND rule.source_id=attachment.id`)) + .run(nowForConditionalMigration, nowForConditionalMigration); +await database.exec(`INSERT OR IGNORE INTO conditional_effect_clauses + (id,rule_id,clause_key,label,trigger_id,parameters_json,conditions_json,reducer_json, + emission_kind,state_pool_id,mutations_json,cooldown_seconds,output_branch,sort_order, + active,created_at,updated_at) + SELECT rule.id||':primary',rule.id,'primary','Primary', + (SELECT link.trigger_id FROM talent_tiers tier + JOIN talent_tier_triggers link ON link.talent_tier_id=tier.id + WHERE tier.talent_id=talent.id ORDER BY CASE tier.tier WHEN 'I' THEN 0 ELSE 1 END,link.trigger_id LIMIT 1), + '{}','{"kind":"true"}','{"kind":"none"}','each_match',rule.id||':effect', + '[{"kind":"add","pool":"effect","value":1}]',talent.cooldown_seconds,'active',1,1, + talent.created_at,talent.updated_at + FROM conditional_effect_rules rule + JOIN talents talent ON rule.source_type='talent' AND rule.source_id=talent.id + WHERE EXISTS ( + SELECT 1 FROM talent_tiers tier + JOIN talent_tier_triggers link ON link.talent_tier_id=tier.id + WHERE tier.talent_id=talent.id + )`); +await database.exec(`INSERT OR IGNORE INTO conditional_effect_clauses + (id,rule_id,clause_key,label,trigger_id,parameters_json,conditions_json,reducer_json, + emission_kind,state_pool_id,mutations_json,cooldown_seconds,output_branch,sort_order, + active,created_at,updated_at) + SELECT rule.id||':primary',rule.id,'primary','Primary', + (SELECT trigger_id FROM os_protocol_talent_triggers WHERE talent_id=talent.id ORDER BY trigger_id LIMIT 1), + '{}','{"kind":"true"}','{"kind":"none"}','each_match',rule.id||':effect', + '[{"kind":"add","pool":"effect","value":1}]',NULL,'active',1,1,talent.created_at,talent.updated_at + FROM conditional_effect_rules rule + JOIN os_protocol_talents talent ON rule.source_type='os_protocol_talent' AND rule.source_id=talent.id + WHERE EXISTS (SELECT 1 FROM os_protocol_talent_triggers WHERE talent_id=talent.id)`); +await database.exec(`INSERT OR IGNORE INTO conditional_effect_clauses + (id,rule_id,clause_key,label,trigger_id,parameters_json,conditions_json,reducer_json, + emission_kind,state_pool_id,mutations_json,cooldown_seconds,output_branch,sort_order, + active,created_at,updated_at) + SELECT rule.id||':primary',rule.id,'primary','Primary', + (SELECT trigger_id FROM skill_chip_talent_triggers WHERE talent_id=talent.id ORDER BY trigger_id LIMIT 1), + '{}','{"kind":"true"}','{"kind":"none"}','each_match',rule.id||':effect', + '[{"kind":"add","pool":"effect","value":1}]',NULL,'active',1,1,talent.created_at,talent.updated_at + FROM conditional_effect_rules rule + JOIN skill_chip_talents talent ON rule.source_type='skill_chip_talent' AND rule.source_id=talent.id + WHERE EXISTS (SELECT 1 FROM skill_chip_talent_triggers WHERE talent_id=talent.id)`); +await (await database.prepare(`INSERT OR IGNORE INTO conditional_effect_clauses + (id,rule_id,clause_key,label,trigger_id,parameters_json,conditions_json,reducer_json, + emission_kind,state_pool_id,mutations_json,cooldown_seconds,output_branch,sort_order, + active,created_at,updated_at) + SELECT rule.id||':primary',rule.id,'primary','Primary','trigger-same-target-hits-window', + '{"hitsRequired":'||attachment.trigger_count||',"windowSeconds":'||attachment.trigger_window_seconds||'}', + '{"kind":"true"}','{"kind":"sliding_count","threshold":'||attachment.trigger_count|| + ',"windowSeconds":'||attachment.trigger_window_seconds||'}', + 'threshold_crossed',rule.id||':effect', + '[{"kind":"add","pool":"effect","value":'||attachment.stacks_per_trigger||'}]', + NULL,'active',1,1,?,? + FROM conditional_effect_rules rule + JOIN specialization_tree_rank_combat_effects attachment + ON rule.source_type='specialization_tree_rank' AND rule.source_id=attachment.id`)) + .run(nowForConditionalMigration, nowForConditionalMigration); +await database.exec(`UPDATE conditional_effect_clauses + SET parameters_json=REPLACE(parameters_json,'"count":','"hitsRequired":') + WHERE rule_id IN ( + SELECT id FROM conditional_effect_rules + WHERE source_type='specialization_tree_rank' AND configured=1 + ) + AND parameters_json LIKE '%"count":%'`); +await database.exec(`WITH canonical_talent_effects AS ( + SELECT ranked.rule_id,ranked.stat_attribute_id, + ROW_NUMBER() OVER( + PARTITION BY ranked.rule_id + ORDER BY COALESCE(ranked.tier_one_order,ranked.any_tier_order),ranked.stat_attribute_id + ) AS effect_order + FROM ( + SELECT rule.id AS rule_id,effect.stat_attribute_id, + MIN(CASE WHEN tier.tier='I' THEN effect.created_at||':'||effect.id END) AS tier_one_order, + MIN(effect.created_at||':'||effect.id) AS any_tier_order + FROM talent_tier_effects effect + JOIN talent_tiers tier ON tier.id=effect.talent_tier_id + JOIN talents talent ON talent.id=tier.talent_id + JOIN conditional_effect_rules rule ON rule.source_type='talent' AND rule.source_id=talent.id + GROUP BY rule.id,effect.stat_attribute_id + ) ranked + ) + INSERT OR IGNORE INTO conditional_effect_outcomes + (id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key,application_kind, + effect_scope,stat_attribute_id,value,value_expression_json,value_basis,value_scaling, + unit,damage_basis,application_phase,min_state_value,max_state_value,description_slot, + sort_order,created_at,updated_at) + SELECT 'conditional-outcome-talent-'||effect.id,rule.id,rule.id||':primary',rule.id||':effect', + 'active-effect-'||canonical.effect_order, + tier.tier,'active','active_modifier', + CASE talent.category WHEN 'Weapon' THEN 'equipped_weapon' ELSE 'self' END, + effect.stat_attribute_id,effect.value,NULL,'flat','per_state_unit',attribute.unit,NULL, + 'while_active',1,NULL, + canonical.effect_order, + canonical.effect_order, + effect.created_at,effect.created_at + FROM talent_tier_effects effect + JOIN talent_tiers tier ON tier.id=effect.talent_tier_id + JOIN talents talent ON talent.id=tier.talent_id + JOIN conditional_effect_rules rule ON rule.source_type='talent' AND rule.source_id=talent.id + JOIN canonical_talent_effects canonical + ON canonical.rule_id=rule.id AND canonical.stat_attribute_id=effect.stat_attribute_id + JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id`); +await database.exec(`INSERT OR IGNORE INTO conditional_effect_outcomes + (id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key,application_kind, + effect_scope,stat_attribute_id,value,value_expression_json,value_basis,value_scaling, + unit,damage_basis,application_phase,min_state_value,max_state_value,description_slot, + sort_order,created_at,updated_at) + SELECT 'conditional-outcome-protocol-'||effect.id,rule.id,rule.id||':primary',rule.id||':effect', + 'active-effect-'||effect.sort_order,'base','active','active_modifier','self', + effect.stat_attribute_id,effect.base_value,NULL,'flat','per_state_unit',attribute.unit,NULL, + 'while_active',1,NULL,effect.sort_order,effect.sort_order,talent.created_at,talent.created_at + FROM os_protocol_talent_effects effect + JOIN os_protocol_talents talent ON talent.id=effect.talent_id + JOIN conditional_effect_rules rule ON rule.source_type='os_protocol_talent' AND rule.source_id=talent.id + JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id`); +await database.exec(`INSERT OR IGNORE INTO conditional_effect_outcomes + (id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key,application_kind, + effect_scope,stat_attribute_id,value,value_expression_json,value_basis,value_scaling, + unit,damage_basis,application_phase,min_state_value,max_state_value,description_slot, + sort_order,created_at,updated_at) + SELECT 'conditional-outcome-chip-'||effect.id,rule.id,rule.id||':primary',rule.id||':effect', + 'active-effect-'||effect.sort_order,'minimum','active','active_modifier','assigned_ability', + effect.stat_attribute_id,effect.min_value,NULL,'flat','per_state_unit',attribute.unit,NULL, + 'while_active',1,NULL,effect.sort_order,effect.sort_order,talent.created_at,talent.created_at + FROM skill_chip_talent_effects effect + JOIN skill_chip_talents talent ON talent.id=effect.talent_id + JOIN conditional_effect_rules rule ON rule.source_type='skill_chip_talent' AND rule.source_id=talent.id + JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id`); +await (await database.prepare(`INSERT OR IGNORE INTO conditional_effect_outcomes + (id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key,application_kind, + effect_scope,stat_attribute_id,value,value_expression_json,value_basis,value_scaling, + unit,damage_basis,application_phase,min_state_value,max_state_value,description_slot, + sort_order,created_at,updated_at) + SELECT 'conditional-outcome-specialization-'||modifier.id,rule.id,rule.id||':primary',rule.id||':effect', + 'active-effect-'||modifier.modifier_order,'base','active','active_modifier','self', + modifier.stat_attribute_id,modifier.value_per_stack,NULL,'flat','per_state_unit',modifier.unit,NULL, + 'while_active',1,NULL,modifier.modifier_order,modifier.modifier_order,?,? + FROM specialization_tree_rank_combat_modifiers modifier + JOIN conditional_effect_rules rule ON rule.source_type='specialization_tree_rank' + JOIN specialization_tree_rank_combat_effects attachment + ON attachment.id=rule.source_id AND attachment.id=modifier.rank_combat_effect_id`)) + .run(nowForConditionalMigration, nowForConditionalMigration); +} + +const sourceOwnedClauseColumns = await (await database.prepare( + "PRAGMA table_info('conditional_effect_clauses')" +)).all(); +if (sourceOwnedClauseColumns.some((column) => column.name === 'trigger_id')) { + console.log('Migrating mechanic clauses from Trigger references to source-owned events.'); + const legacyClauses = await (await database.prepare(` + SELECT clause.*,trigger.action_key + FROM conditional_effect_clauses clause + LEFT JOIN talent_triggers trigger ON trigger.id=clause.trigger_id + ORDER BY clause.rule_id,clause.sort_order + `)).all(); + const jsonObject = (value, fallback) => { + try { + const parsed = JSON.parse(String(value ?? '')); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : fallback; + } catch { + return fallback; + } + }; + const eventReference = (path) => ({ source: 'event', path }); + const targetExists = { kind: 'exists', value: eventReference('targetId') }; + const combinePredicates = (left, right) => { + if (!left || left.kind === 'true') return right ?? { kind: 'true' }; + if (!right || right.kind === 'true') return left; + return { kind: 'all', predicates: [left, right] }; + }; + const actionMapping = (action, parameters) => { + switch (action) { + case 'same_target_hits_within_window': + return { + eventKind: 'weapon_hit', + predicate: targetExists, + reducer: { + kind: 'sliding_count', + threshold: Number(parameters.hitsRequired ?? 1), + windowSeconds: Number(parameters.windowSeconds ?? 1), + reset: 'zero', + groupBy: eventReference('targetId') + } + }; + case 'distinct_enemies_within_window': + return { + eventKind: 'weapon_hit', + predicate: targetExists, + reducer: { + kind: 'distinct_count', + threshold: Number(parameters.enemiesRequired ?? 1), + windowSeconds: Number(parameters.windowSeconds ?? 1), + distinctBy: eventReference('targetId'), + reset: 'zero' + } + }; + case 'consecutive_same_target_hits': + return { + eventKind: 'weapon_hit', + predicate: targetExists, + reducer: { + kind: 'consecutive_count', + threshold: Number(parameters.hitsRequired ?? 1), + groupBy: eventReference('targetId'), + reset: 'zero' + } + }; + case 'continuous_firing_duration': + return { + eventKind: 'firing_started', + reducer: { kind: 'duration', seconds: Number(parameters.seconds ?? 1) }, + capturedOnly: true + }; + case 'while_firing': + return { eventKind: 'firing_started', capturedOnly: true }; + case 'health_threshold_bands': + return { eventKind: 'tick', capturedOnly: true }; + case 'headshot': + return { + eventKind: 'weapon_hit', + predicate: { + kind: 'compare', + left: eventReference('values.headshot'), + operator: 'eq', + right: true + } + }; + case 'headshot_kill': + return { + eventKind: 'enemy_killed', + predicate: { + kind: 'compare', + left: eventReference('values.headshot'), + operator: 'eq', + right: true + } + }; + case 'weapon_kill': + return { eventKind: 'enemy_killed' }; + case 'target_hit_within_distance': + return { + eventKind: 'weapon_hit', + predicate: { + kind: 'all', + predicates: [ + targetExists, + { + kind: 'compare', + left: eventReference('values.distanceMeters'), + operator: 'lte', + right: Number(parameters.meters ?? 0) + } + ] + } + }; + case 'damage_taken': + return { eventKind: 'health_damage_taken' }; + case 'weapon_swap': + return { eventKind: 'weapon_swapped' }; + case 'reload': + return { eventKind: 'reload_started' }; + case 'within_distance': + return { + eventKind: 'proximity_snapshot', + predicate: { + kind: 'compare', + left: eventReference('values.distanceMeters'), + operator: 'lte', + right: Number(parameters.meters ?? 0) + }, + capturedOnly: true + }; + case 'extra_health_active': + return { + eventKind: 'tick', + predicate: { + kind: 'compare', + left: { source: 'combat', path: 'values.extraHealth' }, + operator: 'gt', + right: 0 + }, + capturedOnly: true + }; + default: + if ([ + 'shot_fired','weapon_hit','skill_deployed','skill_hit','agent_hit', + 'health_damage_taken','damage_dealt','enemy_killed','assist','healing_applied', + 'extra_health_granted','movement_snapshot','proximity_snapshot','firing_started', + 'firing_stopped','reload_started','reload_completed','weapon_swapped','tick' + ].includes(String(action))) return { eventKind: String(action) }; + return null; + } + }; + const migratedClauses = legacyClauses.map((clause) => { + const parameters = jsonObject(clause.parameters_json, {}); + const existingPredicate = jsonObject(clause.conditions_json, { kind: 'true' }); + const existingReducer = jsonObject(clause.reducer_json, { kind: 'none' }); + const mapping = actionMapping(String(clause.action_key ?? ''), parameters); + if (!mapping) { + throw new Error(`Cannot migrate mechanic clause ${clause.id}: unsupported Trigger action ${clause.action_key ?? 'NULL'}.`); + } + return { + ...clause, + event_kind: mapping.eventKind, + recipe_key: String(clause.action_key ?? '') || null, + recipe_parameters_json: JSON.stringify(parameters), + conditions_json: JSON.stringify(combinePredicates(mapping.predicate, existingPredicate)), + reducer_json: JSON.stringify( + existingReducer.kind === 'none' && mapping.reducer ? mapping.reducer : existingReducer + ), + captured_only: Boolean(mapping.capturedOnly) + }; + }); + + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec('DROP VIEW IF EXISTS conditional_effect_rule_primary'); + await database.exec('ALTER TABLE conditional_effect_clauses RENAME TO conditional_effect_clauses_triggered'); + await database.exec('ALTER TABLE conditional_effect_outcomes RENAME TO conditional_effect_outcomes_triggered'); + await database.exec(`CREATE TABLE conditional_effect_clauses ( + id TEXT PRIMARY KEY, + rule_id TEXT NOT NULL REFERENCES conditional_effect_rules(id) ON DELETE CASCADE, + clause_key TEXT NOT NULL, + label TEXT NOT NULL, + event_kind TEXT NOT NULL, + recipe_key TEXT, + recipe_parameters_json TEXT NOT NULL DEFAULT '{}', + conditions_json TEXT NOT NULL DEFAULT '{"kind":"true"}', + reducer_json TEXT NOT NULL DEFAULT '{"kind":"none"}', + weight_expression_json TEXT, + emission_kind TEXT NOT NULL DEFAULT 'each_match' CHECK(emission_kind IN ( + 'each_match','threshold_crossed','while_true','interval_elapsed','value_changed' + )), + state_pool_id TEXT REFERENCES conditional_effect_state_pools(id) ON DELETE SET NULL, + mutations_json TEXT NOT NULL DEFAULT '[]', + cooldown_seconds REAL CHECK(cooldown_seconds IS NULL OR cooldown_seconds>0), + output_branch TEXT NOT NULL DEFAULT 'active', + sort_order INTEGER NOT NULL CHECK(sort_order>=1), + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(rule_id,clause_key), + UNIQUE(rule_id,sort_order) + )`); + const insertClause = await database.prepare(`INSERT INTO conditional_effect_clauses + (id,rule_id,clause_key,label,event_kind,recipe_key,recipe_parameters_json, + conditions_json,reducer_json,weight_expression_json,emission_kind,state_pool_id, + mutations_json,cooldown_seconds,output_branch,sort_order,active,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`); + for (const clause of migratedClauses) { + await insertClause.run( + clause.id, + clause.rule_id, + clause.clause_key, + clause.label, + clause.event_kind, + clause.recipe_key, + clause.recipe_parameters_json, + clause.conditions_json, + clause.reducer_json, + null, + clause.emission_kind, + clause.state_pool_id, + clause.mutations_json, + clause.cooldown_seconds, + clause.output_branch, + clause.sort_order, + clause.active, + clause.created_at, + clause.updated_at + ); + if (clause.captured_only) { + await (await database.prepare( + "UPDATE conditional_effect_rules SET simulation_readiness='captured_only' WHERE id=?" + )).run(clause.rule_id); + } + } + await database.exec(`CREATE TABLE conditional_effect_outcomes ( + id TEXT PRIMARY KEY, + rule_id TEXT NOT NULL REFERENCES conditional_effect_rules(id) ON DELETE CASCADE, + clause_id TEXT REFERENCES conditional_effect_clauses(id) ON DELETE CASCADE, + state_pool_id TEXT REFERENCES conditional_effect_state_pools(id) ON DELETE SET NULL, + effect_key TEXT NOT NULL, + variant_key TEXT NOT NULL DEFAULT 'base', + outcome_key TEXT NOT NULL DEFAULT 'active', + application_kind TEXT NOT NULL DEFAULT 'active_modifier' CHECK(application_kind IN ( + 'active_modifier','direct_damage','restore_health','restore_armor','restore_ammo','resource_cost','marker' + )), + effect_scope TEXT NOT NULL DEFAULT 'self' CHECK(effect_scope IN ( + 'self','equipped_weapon','target','allies','self_and_allies','assigned_ability','all_skills' + )), + stat_attribute_id TEXT REFERENCES stat_attributes(id), + value REAL NOT NULL, + value_expression_json TEXT, + value_basis TEXT NOT NULL DEFAULT 'flat' CHECK(value_basis IN ( + 'flat','max_health_percent','current_health_percent','weapon_base_percent' + )), + value_scaling TEXT NOT NULL DEFAULT 'per_state_unit' CHECK(value_scaling IN ('flat','per_state_unit')), + unit TEXT, + damage_basis TEXT CHECK(damage_basis IS NULL OR damage_basis IN ('flat','weapon_base_percent')), + application_phase TEXT NOT NULL DEFAULT 'while_active' CHECK(application_phase IN ( + 'while_active','on_event','after_event','on_enter','on_exit' + )), + combat_timing TEXT NOT NULL DEFAULT 'current' CHECK(combat_timing IN ('pre_event','current')), + resource_kind TEXT CHECK(resource_kind IS NULL OR resource_kind IN ('health','armor','ammo')), + min_state_value REAL, + max_state_value REAL, + description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1), + sort_order INTEGER NOT NULL CHECK(sort_order>=1), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(rule_id,variant_key,outcome_key,sort_order), + CHECK(min_state_value IS NULL OR min_state_value>=0), + CHECK(max_state_value IS NULL OR max_state_value>=0), + CHECK(min_state_value IS NULL OR max_state_value IS NULL OR max_state_value>=min_state_value), + CHECK( + (application_kind='active_modifier' AND stat_attribute_id IS NOT NULL AND damage_basis IS NULL) + OR (application_kind='direct_damage' AND damage_basis IS NOT NULL) + OR application_kind IN ('restore_health','restore_armor','restore_ammo','resource_cost','marker') + ) + )`); + await database.exec(`INSERT INTO conditional_effect_outcomes + (id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key, + application_kind,effect_scope,stat_attribute_id,value,value_expression_json, + value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing, + resource_kind,min_state_value,max_state_value,description_slot,sort_order, + created_at,updated_at) + SELECT id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key, + application_kind,effect_scope,stat_attribute_id,value,value_expression_json, + value_basis,value_scaling,unit,damage_basis,application_phase,'current', + CASE application_kind + WHEN 'restore_health' THEN 'health' + WHEN 'restore_armor' THEN 'armor' + WHEN 'restore_ammo' THEN 'ammo' + ELSE NULL END, + min_state_value,max_state_value,description_slot,sort_order,created_at,updated_at + FROM conditional_effect_outcomes_triggered`); + await database.exec('DROP TABLE conditional_effect_outcomes_triggered'); + await database.exec('DROP TABLE conditional_effect_clauses_triggered'); + await database.exec('DROP TABLE IF EXISTS talent_tier_triggers'); + await database.exec('DROP TABLE IF EXISTS os_protocol_talent_triggers'); + await database.exec('DROP TABLE IF EXISTS skill_chip_talent_triggers'); + await database.exec('DROP TABLE IF EXISTS talent_triggers'); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } + await database.exec(`CREATE INDEX IF NOT EXISTS conditional_effect_clauses_event_idx + ON conditional_effect_clauses(event_kind,rule_id)`); + await database.exec(`CREATE INDEX IF NOT EXISTS conditional_effect_outcomes_rule_idx + ON conditional_effect_outcomes(rule_id,variant_key,outcome_key,sort_order)`); +} + +const legacyTalentOutcomes = await (await database.prepare(` + SELECT outcome.id,outcome.rule_id,outcome.variant_key,outcome.stat_attribute_id, + outcome.effect_key,outcome.description_slot,outcome.sort_order + FROM conditional_effect_outcomes outcome + JOIN conditional_effect_rules rule ON rule.id=outcome.rule_id + WHERE rule.source_type='talent' + AND outcome.id LIKE 'conditional-outcome-talent-%' + AND outcome.variant_key IN ('I','II') + ORDER BY outcome.rule_id,outcome.variant_key,outcome.sort_order,outcome.id +`)).all(); +const legacyTalentOutcomesByRule = new Map(); +for (const outcome of legacyTalentOutcomes) { + const rows = legacyTalentOutcomesByRule.get(outcome.rule_id) ?? []; + rows.push(outcome); + legacyTalentOutcomesByRule.set(outcome.rule_id, rows); +} +const legacyTalentOutcomeRepairs = []; +for (const [ruleId, rows] of legacyTalentOutcomesByRule) { + const tierOne = rows + .filter((row) => row.variant_key === 'I') + .sort((left, right) => + Number(left.description_slot ?? left.sort_order) - Number(right.description_slot ?? right.sort_order) + || String(left.id).localeCompare(String(right.id))); + const tierTwo = rows.filter((row) => row.variant_key === 'II'); + if (!tierOne.length || !tierTwo.length) continue; + const tierOneAttributes = new Set(tierOne.map((row) => String(row.stat_attribute_id ?? ''))); + const tierTwoAttributes = new Set(tierTwo.map((row) => String(row.stat_attribute_id ?? ''))); + if ( + tierOneAttributes.size !== tierTwoAttributes.size + || [...tierOneAttributes].some((attributeId) => !tierTwoAttributes.has(attributeId)) + ) { + throw new Error(`Cannot repair legacy talent mechanic ${ruleId}: Tier I and Tier II Attributes differ.`); + } + const slotByAttribute = new Map( + tierOne.map((row, index) => [String(row.stat_attribute_id), index + 1]) + ); + for (const row of rows) { + const slot = slotByAttribute.get(String(row.stat_attribute_id)); + if (!slot) continue; + if ( + row.effect_key !== `active-effect-${slot}` + || Number(row.description_slot) !== slot + || Number(row.sort_order) !== slot + ) { + legacyTalentOutcomeRepairs.push({ id: row.id, slot }); + } + } +} +if (legacyTalentOutcomeRepairs.length) { + console.log(`Repairing ${legacyTalentOutcomeRepairs.length} legacy Talent mechanic value pairings.`); + await database.exec('BEGIN'); + try { + const temporaryOrder = await database.prepare( + 'UPDATE conditional_effect_outcomes SET sort_order=sort_order+1000000 WHERE id=?' + ); + for (const repair of legacyTalentOutcomeRepairs) await temporaryOrder.run(repair.id); + const repairOutcome = await database.prepare(` + UPDATE conditional_effect_outcomes + SET effect_key=?,description_slot=?,sort_order=?,updated_at=? + WHERE id=? + `); + const repairedAt = new Date().toISOString(); + for (const repair of legacyTalentOutcomeRepairs) { + await repairOutcome.run( + `active-effect-${repair.slot}`, + repair.slot, + repair.slot, + repairedAt, + repair.id + ); + } + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } +} + const loginEventsDefinition = await (await database.prepare( "SELECT sql FROM sqlite_master WHERE type='table' AND name='login_events'" )).get(); @@ -320,6 +1215,64 @@ if (typeof calculationProposalDefinition?.sql === 'string' && !calculationPropos await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL'); } +const triggerProposalDefinition = await (await database.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'" +)).get(); +if (typeof triggerProposalDefinition?.sql === 'string' && triggerProposalDefinition.sql.includes("'triggers'")) { + console.log('Retiring standalone Trigger proposals in favor of source-owned mechanic programs.'); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec(`CREATE TABLE data_change_proposals_source_owned ( + id TEXT PRIMARY KEY, + module TEXT NOT NULL CHECK(module IN ('configuration','calculations','attributes','talents','brands','gear','weapons','weaponmods','gearmods','protocols','skillchips','specializations')), + action TEXT NOT NULL, + entity_id TEXT, + draft_entity_id TEXT, + applied_entity_id TEXT, + summary TEXT NOT NULL, + payload_json TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')), + proposed_by TEXT NOT NULL REFERENCES users(id), + reviewed_by TEXT REFERENCES users(id), + review_note TEXT, + created_at TEXT NOT NULL, + reviewed_at TEXT, + applied_at TEXT + )`); + await database.exec(`INSERT INTO data_change_proposals_source_owned + SELECT id, + CASE WHEN module='triggers' THEN 'talents' ELSE module END, + CASE WHEN module='triggers' THEN 'legacyTrigger.'||action ELSE action END, + entity_id,draft_entity_id,applied_entity_id,summary,payload_json, + CASE WHEN module='triggers' AND status='pending' THEN 'failed' ELSE status END, + proposed_by,reviewed_by, + CASE + WHEN module='triggers' AND status='pending' + THEN 'Standalone Trigger proposals were retired when mechanics became source-owned.' + ELSE review_note + END, + created_at, + CASE + WHEN module='triggers' AND status='pending' THEN CURRENT_TIMESTAMP + ELSE reviewed_at + END, + applied_at + FROM data_change_proposals`); + await database.exec('DROP TABLE data_change_proposals'); + await database.exec('ALTER TABLE data_change_proposals_source_owned RENAME TO data_change_proposals'); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } + await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)'); + await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)'); + await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL'); +} + const iconDefinition = await (await database.prepare( "SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'" )).get(); diff --git a/scripts/isac-data-export.mjs b/scripts/isac-data-export.mjs index e940511..28311c9 100644 --- a/scripts/isac-data-export.mjs +++ b/scripts/isac-data-export.mjs @@ -38,7 +38,7 @@ for (const row of [...(iconTable?.rows ?? []), ...(managedImageTable?.rows ?? [] const snapshot = { format: 'shd-build-isac-data', - version: 1, + version: 2, createdAt: new Date().toISOString(), sourceDatabase: basename(databasePath), totalRows, diff --git a/scripts/isac-data-import.mjs b/scripts/isac-data-import.mjs index a608537..45dcd49 100644 --- a/scripts/isac-data-import.mjs +++ b/scripts/isac-data-import.mjs @@ -13,7 +13,7 @@ if (!snapshotArgument || !process.argv.includes('--confirm-replace-isac-data')) const snapshotPath = resolve(snapshotArgument); const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8')); -if (snapshot?.format !== 'shd-build-isac-data' || snapshot?.version !== 1 || !Array.isArray(snapshot.tables) || !Array.isArray(snapshot.icons)) { +if (snapshot?.format !== 'shd-build-isac-data' || snapshot?.version !== 2 || !Array.isArray(snapshot.tables) || !Array.isArray(snapshot.icons)) { throw new Error('The selected file is not a supported SHD.build ISAC data snapshot.'); } const receivedNames = snapshot.tables.map((table) => table.name); diff --git a/scripts/isac-data-tables.mjs b/scripts/isac-data-tables.mjs index 22b4168..f7fc31a 100644 --- a/scripts/isac-data-tables.mjs +++ b/scripts/isac-data-tables.mjs @@ -2,8 +2,10 @@ export const ISAC_DATA_TABLES = [ 'stat_attributes', 'talents', 'talent_tiers', - 'talent_triggers', - 'talent_tier_triggers', + 'conditional_effect_rules', + 'conditional_effect_state_pools', + 'conditional_effect_clauses', + 'conditional_effect_outcomes', 'talent_tier_effects', 'talent_description_templates', 'talent_weapon_scopes', @@ -26,14 +28,12 @@ export const ISAC_DATA_TABLES = [ 'os_protocol_secondary_profiles', 'os_protocol_secondary_effects', 'os_protocol_talents', - 'os_protocol_talent_triggers', 'os_protocol_talent_effects', 'skill_chip_sets', 'skill_chip_two_piece_bonuses', 'skill_chip_primary_options', 'skill_chip_secondary_options', 'skill_chip_talents', - 'skill_chip_talent_triggers', 'skill_chip_talent_effects', 'weapon_mods', 'weapon_mod_compatible_categories', @@ -55,6 +55,7 @@ export const ISAC_DATA_TABLES = [ 'specialization_combat_effects', 'specialization_focuses', 'specialization_abilities', + 'specialization_ability_attacks', 'specialization_ability_effects', 'specialization_focus_abilities', 'specialization_tree_nodes', @@ -66,6 +67,7 @@ export const ISAC_DATA_TABLES = [ 'calculation_revisions', 'calculation_stages', 'attribute_calculation_profiles', + 'skill_attribute_calculation_profiles', 'calculation_calibration_cases', 'game_data_icons', 'managed_images' diff --git a/src/app.css b/src/app.css index 28c4f10..a603ace 100644 --- a/src/app.css +++ b/src/app.css @@ -253,10 +253,12 @@ /* Specialization combat modeling: shared authoring and builder patterns. */ .combat-effect-definition-grid{grid-template-columns:minmax(220px,.7fr) minmax(360px,1.3fr)}.combat-effect-template textarea{min-height:86px} +.specialization-attack-profile{display:grid;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-model-border);border-left:2px solid var(--shd-info);background:var(--shd-model-surface)}.specialization-attack-profile>.segmented-choice{margin:0}.specialization-attack-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--shd-gap-sm);align-items:end}.specialization-attack-grid>.field,.specialization-attack-grid>.segmented-choice{margin:0}.calculation-note{margin:0;padding:8px;border:1px solid var(--shd-border-soft);color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45} .rank-combat-effects{display:grid;gap:var(--shd-gap-sm);margin-top:var(--shd-gap-md);padding-top:var(--shd-gap-md);border-top:1px solid var(--shd-border)}.rank-combat-effect{display:grid;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-model-border);border-left:2px solid var(--shd-info);background:var(--shd-model-surface)}.rank-combat-effect>header{display:flex;align-items:center;justify-content:space-between}.rank-combat-effect>header>div{display:grid;gap:2px}.rank-combat-effect>header span,.rank-combat-effect>header small{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.rank-combat-effect>header span{color:var(--shd-info)}.combat-trigger-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:var(--shd-gap-sm)}.combat-lifecycle-facts{display:flex;flex-wrap:wrap;gap:var(--shd-gap-xs)}.combat-lifecycle-facts span{padding:5px 7px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.6px}.combat-lifecycle-facts b{color:var(--shd-text)}.combat-modifier-list{display:grid;gap:var(--shd-gap-xs)}.combat-modifier-list>div{display:grid;grid-template-columns:minmax(220px,1fr) 150px 140px auto;gap:var(--shd-gap-sm);align-items:end;padding:6px;border-top:1px solid var(--shd-border-soft)}.combat-modifier-list>div>.field{margin:0} .builder-remediation{display:grid;gap:3px;padding:9px;border:1px solid var(--shd-danger);border-left-width:2px;background:#1d1212}.builder-remediation strong{color:var(--shd-danger);font-size:var(--shd-type-telemetry);letter-spacing:.9px}.builder-remediation span{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.builder-specialization-identity{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:var(--shd-gap-md);align-items:center;padding:var(--shd-gap-md);border:1px solid var(--shd-model-border);border-left:2px solid var(--shd-orange);background:var(--shd-model-surface)}.builder-specialization-identity h3,.builder-specialization-identity p{margin:0}.builder-specialization-identity small,.builder-specialization-identity>b{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.builder-specialization-identity p{margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.builder-specialization-identity>b{padding:5px 7px;border:1px solid var(--shd-border);color:var(--shd-text-muted)}.builder-specialization-identity>b.configured{border-color:#365d44;color:var(--shd-success)}.builder-ability-chip-panels{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--shd-gap-sm)}.builder-ability-chip-panels>section{display:grid;grid-template-rows:auto minmax(48px,auto) auto;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-border);background:var(--shd-surface)}.builder-ability-chip-panels header{display:grid;grid-template-columns:auto 1fr auto;gap:var(--shd-gap-sm);align-items:center}.builder-ability-chip-panels header img{width:38px;height:38px}.builder-ability-chip-panels header div{display:grid}.builder-ability-chip-panels header small,.builder-ability-chip-panels header b{font-size:var(--shd-type-telemetry);letter-spacing:.7px}.builder-ability-chip-panels>section>p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.builder-ability-chip-panels>section>div{display:grid;gap:4px}.builder-ability-chip-panels>section>div>button{min-height:54px;display:grid;grid-template-columns:70px 1fr;grid-template-rows:auto auto;gap:1px 8px;padding:7px;border:1px solid var(--shd-border);background:#0d1011;color:var(--shd-text);text-align:left;cursor:pointer}.builder-ability-chip-panels>section>div>button:hover,.builder-ability-chip-panels>section>div>button:focus-visible{border-color:var(--shd-orange)}.builder-ability-chip-panels button small{grid-row:1/3;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}.builder-ability-chip-panels button span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.builder-specialization-telemetry{display:flex;flex-wrap:wrap;gap:var(--shd-gap-xs)}.builder-specialization-telemetry span{padding:6px 8px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.builder-specialization-telemetry b{color:var(--shd-orange)}.builder-focus-tree{display:grid;gap:var(--shd-gap-sm)}.builder-focus-tree>section{border:1px solid var(--shd-border);background:var(--shd-model-surface)}.builder-focus-tree>section>header{display:flex;justify-content:space-between;padding:6px 8px;border-bottom:1px solid var(--shd-border);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.builder-focus-tree>section>header span{color:var(--shd-orange)}.builder-focus-tree>section>div{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:var(--shd-gap-xs);padding:var(--shd-gap-xs)}.builder-focus-tree article{display:grid;grid-template-columns:auto 1fr;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-border);background:var(--shd-surface)}.builder-focus-tree article>img{width:42px;height:42px}.builder-focus-tree article>div{display:grid;gap:3px}.builder-focus-tree article small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.35}.builder-focus-tree article>.segmented-choice{grid-column:1/-1;margin:0}.segmented-choice span.unavailable{opacity:.38;cursor:not-allowed;filter:saturate(.25)}.builder-conditional-effects,.builder-combat-results{display:grid;gap:var(--shd-gap-sm)}.builder-combat-results{padding:var(--shd-gap-sm);border-bottom:1px solid var(--shd-border)}.builder-return-link{width:max-content;min-height:36px;padding:7px 9px;border:1px solid var(--shd-border);background:transparent;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;cursor:pointer}.builder-return-link:hover,.builder-return-link:focus-visible{border-color:var(--shd-orange)} -@media(max-width:1050px){.combat-trigger-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.combat-modifier-list>div{grid-template-columns:1fr 1fr}.builder-ability-chip-panels{grid-template-columns:1fr}.builder-specialization-identity{grid-template-columns:auto 1fr}.builder-specialization-identity>b{grid-column:1/-1;width:max-content}} -@media(max-width:650px){.combat-effect-definition-grid,.combat-trigger-grid,.combat-modifier-list>div{grid-template-columns:1fr}.rank-combat-effect button,.builder-return-link,.builder-ability-chip-panels button{min-height:44px}.builder-specialization-identity{grid-template-columns:1fr}.builder-specialization-identity .focus-composite-icon{justify-self:start}} +.builder-skill-activation{display:grid;gap:var(--shd-gap-sm)}.builder-skill-activation>section{display:grid;grid-template-columns:minmax(180px,.7fr) minmax(340px,1.3fr);gap:var(--shd-gap-sm);align-items:end;padding:var(--shd-gap-sm);border:1px solid var(--shd-model-border);border-left:2px solid var(--shd-info);background:var(--shd-model-surface)}.builder-skill-activation>section>header{display:grid;grid-template-columns:auto 1fr;gap:var(--shd-gap-sm);align-items:center}.builder-skill-activation>section>header img{width:40px;height:40px}.builder-skill-activation>section>header div{display:grid;gap:2px}.builder-skill-activation>section>header small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.builder-skill-activation>section>.segmented-choice{margin:0}.builder-skill-assumptions{grid-column:1/-1;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--shd-gap-sm)} +@media(max-width:1050px){.combat-trigger-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.combat-modifier-list>div{grid-template-columns:1fr 1fr}.builder-ability-chip-panels{grid-template-columns:1fr}.builder-specialization-identity{grid-template-columns:auto 1fr}.builder-specialization-identity>b{grid-column:1/-1;width:max-content}.builder-skill-activation>section{grid-template-columns:1fr}.specialization-attack-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} +@media(max-width:650px){.combat-effect-definition-grid,.combat-trigger-grid,.combat-modifier-list>div,.specialization-attack-grid{grid-template-columns:1fr}.rank-combat-effect button,.builder-return-link,.builder-ability-chip-panels button{min-height:44px}.builder-specialization-identity{grid-template-columns:1fr}.builder-specialization-identity .focus-composite-icon{justify-self:start}.builder-skill-assumptions{grid-template-columns:1fr}} .community-index-hero>div:last-child .primary{ font-size:var(--shd-type-telemetry); } diff --git a/src/lib/builder/loadout-combat.test.ts b/src/lib/builder/loadout-combat.test.ts new file mode 100644 index 0000000..9a2a7bf --- /dev/null +++ b/src/lib/builder/loadout-combat.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; +import { evaluateMechanicProgram, type MechanicProgram } from '$lib/calculations/mechanic'; +import { createDraft } from './loadout.js'; +import { + buildWeaponMechanicTimeline, + mechanicModifiersAt, + weaponCriticalChance +} from './loadout-combat.js'; + +describe('loadout mechanic timeline', () => { + it('emits deterministic critical hits from modeled critical chance', () => { + const profile = createDraft().combatProfile; + profile.encounterSeconds = 4; + profile.hitRate = 100; + profile.shotsPerBurst = 100; + profile.burstPauseSeconds = 0; + + const timeline = buildWeaponMechanicTimeline( + { rpm: 60, magazine: 100, reload_speed: 0 }, + profile, + 50 + ); + const criticalHits = timeline + .filter((event) => event.kind === 'weapon_hit') + .map((event) => event.values?.critical); + + expect(criticalHits).toEqual([false, true, false, true, false]); + }); + + it('derives critical chance through the configured binding transform', () => { + expect(weaponCriticalChance( + { critical: 80 }, + [{ + attributeId: 'critical', + stage: 'critical_chance', + operator: 'add', + weight: 0.5, + hardCap: 30, + curveType: 'none' + }] + )).toBe(30); + }); +}); + +describe('per-shot mechanic modifiers', () => { + const program: MechanicProgram = { + id: 'phase-order', + name: 'Phase order', + pools: [{ + key: 'stacks', + label: 'Stacks', + scope: 'agent', + initial: 0, + min: 0, + max: 10, + expiresAfterSeconds: 10 + }], + clauses: [ + { + id: 'stack-on-hit', + when: 'weapon_hit', + reducer: { kind: 'none' }, + mutations: [{ kind: 'add', pool: 'stacks', value: 1 }], + outcomes: [ + { + id: 'instant-a', + label: 'Instant A', + phase: 'on_event', + kind: 'attribute_modifier', + scope: 'equipped_weapon', + value: 2, + basis: 'flat', + attributeId: 'weapon-damage' + }, + { + id: 'after-hit', + label: 'After hit', + phase: 'after_event', + kind: 'attribute_modifier', + scope: 'equipped_weapon', + value: 100, + basis: 'flat', + attributeId: 'weapon-damage' + }, + { + id: 'active-stacks', + label: 'Active stacks', + phase: 'while_active', + kind: 'attribute_modifier', + scope: 'equipped_weapon', + value: 3, + basis: 'flat', + attributeId: 'weapon-damage', + statePool: 'stacks', + perStack: true + } + ] + }, + { + id: 'second-instant', + when: 'weapon_hit', + reducer: { kind: 'none' }, + mutations: [], + outcomes: [{ + id: 'instant-b', + label: 'Instant B', + phase: 'on_event', + kind: 'attribute_modifier', + scope: 'equipped_weapon', + value: 5, + basis: 'flat', + attributeId: 'weapon-damage' + }] + } + ] + }; + const evaluation = evaluateMechanicProgram( + program, + [ + { kind: 'weapon_hit', at: 1, targetId: 'target' }, + { kind: 'weapon_hit', at: 2, targetId: 'target' } + ], + { mode: 'custom', encounterSeconds: 5 } + ); + const source = { + status: 'OK' as const, + result: evaluation, + program, + encounterSeconds: 5 + }; + + it('combines same-attribute on-event effects and excludes after-event effects', () => { + expect(mechanicModifiersAt(source, 1, 'weapon_hit')).toEqual([{ + attributeId: 'weapon-damage', + scope: 'equipped_weapon', + value: 7 + }]); + }); + + it('uses the pre-hit active stack state when the triggering hit adds another stack', () => { + expect(mechanicModifiersAt(source, 1.5, 'weapon_hit')[0]?.value).toBe(3); + expect(mechanicModifiersAt(source, 2, 'weapon_hit')[0]?.value).toBe(10); + }); +}); diff --git a/src/lib/builder/loadout-combat.ts b/src/lib/builder/loadout-combat.ts index 6cc1fff..e31ee06 100644 --- a/src/lib/builder/loadout-combat.ts +++ b/src/lib/builder/loadout-combat.ts @@ -1,8 +1,26 @@ import { calculateCombat, + expectedWeaponShotDamage, + resolveWeaponFiringCycle, type AttributeBinding, type CalculationStage } from '$lib/calculations/combat'; +import { + evaluateMechanicProgram, + type MechanicCombatContext, + type MechanicEvaluation, + type MechanicEvent, + type MechanicOutcome, + type MechanicProgram, + type NumericExpression +} from '$lib/calculations/mechanic'; +import { adaptConditionalEffectProgram } from '$lib/calculations/mechanic-persistence'; +import { + calculateSkillDps, + type SkillAttributeBinding, + type SkillCalculation, + type SkillModifierWindow +} from '$lib/calculations/skill'; import { gearSlots, scaled, type LoadoutDraft } from '$lib/builder/loadout'; import type { WeaponCalculationResult } from '$lib/components/builder/WeaponCalculationResult.svelte'; @@ -16,7 +34,7 @@ type Catalog = { chipSets: any[]; protocols: any[]; specializations: any[]; - calculation: { revision: any | null; stages: any[]; profiles: any[] }; + calculation: { revision: any | null; stages: any[]; profiles: any[]; skillProfiles?: any[] }; }; export type LoadoutSummary = { @@ -47,6 +65,16 @@ function attribute(catalog: Catalog, id: string) { return catalog.attributes.find((item) => item.id === id); } +function applyAttributeValue(catalog: Catalog, current: number, statId: string, incoming: number) { + const stat = attribute(catalog, statId); + const combined = stat?.calculation_type === 'Multiplicative' && stat?.unit === '%' + ? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100 + : current + incoming; + return stat?.cap_value === null || stat?.cap_value === undefined + ? combined + : Math.min(combined, Number(stat.cap_value)); +} + function unitValue(catalog: Catalog, value: unknown, statId: string) { const numeric = Number(value); const rendered = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/\.?0+$/, ''); @@ -118,6 +146,7 @@ export function summarizeLoadout(draft: LoadoutDraft, catalog: Catalog): Loadout equippedTalents.push({ key: `gear:${slot}:${selection.talentId}`, id: selection.talentId, + rule: catalog.talents.find((entry) => entry.id === selection.talentId)?.rule ?? null, tier: Number(item.tier), source: slot, description: talentDescription(catalog, selection.talentId, Number(item.tier)) @@ -152,6 +181,7 @@ export function summarizeLoadout(draft: LoadoutDraft, catalog: Catalog): Loadout equippedTalents.push({ key: `weapon:${index}:${talentId}`, id: talentId, + rule: catalog.talents.find((entry) => entry.id === talentId)?.rule ?? null, tier: selection.tier, source: index ? 'Secondary weapon' : 'Primary weapon', description: talentDescription(catalog, talentId, selection.tier) @@ -188,7 +218,9 @@ export function summarizeLoadout(draft: LoadoutDraft, catalog: Catalog): Loadout key: `chips:${group.item.id}`, id: '', custom: group.item.talent, + rule: group.item.talent.rule ?? null, tier: group.highEnd, + abilityId: group.item.specialization_ability_id ?? null, source: '3 Skill Chips', description: group.item.talent.description_template.replace(/\{(\d+)\}/g, (placeholder: string, raw: string) => { const effect = group.item.talent.effects[Number(raw) - 1]; @@ -223,6 +255,7 @@ export function summarizeLoadout(draft: LoadoutDraft, catalog: Catalog): Loadout key: `protocol:${protocol.id}`, id: '', custom: protocol.talent, + rule: protocol.talent.rule ?? null, tier: draft.protocol.talentUpgrades, source: 'OS Protocol', description: protocol.talent.description_template.replace(/\{(\d+)\}/g, (placeholder: string, raw: string) => { @@ -239,19 +272,6 @@ export function summarizeLoadout(draft: LoadoutDraft, catalog: Catalog): Loadout } } - for (const equipped of equippedTalents) { - if (!draft.activeTalents.includes(equipped.key)) continue; - const effects = equipped.custom?.effects ?? talentTier(catalog, equipped.id, equipped.tier)?.effects ?? []; - for (const effect of effects) { - const value = equipped.key.startsWith('chips:') - ? scaled(effect.min_value, effect.max_value, equipped.tier, 3) - : equipped.key.startsWith('protocol:') - ? Number(effect.base_value) * (1 + equipped.tier / 5) - : effect.value; - add(effect.stat_attribute_id, value, `${equipped.source} talent`); - } - } - return { stats: [...values] .map(([id, value]) => ({ id, value, sourceCount: sources.get(id)?.size ?? 0 })) @@ -265,7 +285,18 @@ export function summarizeLoadout(draft: LoadoutDraft, catalog: Catalog): Loadout } function calculationStage(catalog: Catalog, stageKey: string): CalculationStage | null { - if (['flat_damage', 'weapon_damage', 'critical_chance', 'critical_damage', 'headshot_chance', 'headshot_damage', 'final_damage'].includes(stageKey)) { + if ([ + 'flat_damage', + 'weapon_damage', + 'critical_chance', + 'critical_damage', + 'headshot_chance', + 'headshot_damage', + 'rate_of_fire', + 'magazine_size', + 'reload_speed', + 'final_damage' + ].includes(stageKey)) { return stageKey as CalculationStage; } const stage = catalog.calculation.stages.find((item) => item.stage_key === stageKey); @@ -275,6 +306,9 @@ function calculationStage(catalog: Catalog, stageKey: string): CalculationStage if (stage.operator === 'multiplicative_percent') return 'final_damage'; if (stage.operator === 'expected_critical') return stageKey.includes('chance') ? 'critical_chance' : 'critical_damage'; if (stage.operator === 'expected_headshot') return stageKey.includes('chance') ? 'headshot_chance' : 'headshot_damage'; + if (stage.operator === 'rate_of_fire') return 'rate_of_fire'; + if (stage.operator === 'magazine_size') return 'magazine_size'; + if (stage.operator === 'reload_speed') return 'reload_speed'; return null; } @@ -298,6 +332,516 @@ function calculationBindings(catalog: Catalog, weapon: any): AttributeBinding[] }); } +function transformedBindingValue(raw: number, binding: AttributeBinding) { + const weight = Number.isFinite(binding.weight) ? Number(binding.weight) : 1; + const sign = raw < 0 ? -1 : 1; + let value = Math.abs(raw * weight); + const softCap = Number.isFinite(Number(binding.softCap)) && Number(binding.softCap) >= 0 + ? Number(binding.softCap) + : null; + const hardCap = Number.isFinite(Number(binding.hardCap)) && Number(binding.hardCap) >= 0 + ? Number(binding.hardCap) + : null; + if (softCap !== null && value > softCap && binding.curveType && binding.curveType !== 'none') { + const parameter = Math.max(0.01, Number(binding.curveParameter ?? 1)); + const overflow = value - softCap; + if (binding.curveType === 'power') { + value = softCap + overflow * Math.pow(softCap / Math.max(softCap + overflow, 0.000_001), parameter); + } else { + const ceiling = hardCap ?? Math.max(softCap * 2, softCap + 1); + const ratio = Math.min(1, Math.max(0, overflow / Math.max(ceiling - softCap, 0.000_001))); + value = softCap + (ceiling - softCap) * (ratio * ratio * (3 - 2 * ratio)); + } + } + if (hardCap !== null) value = Math.min(value, hardCap); + return value * sign; +} + +export function weaponCriticalChance( + attributes: Record, + bindings: AttributeBinding[] +) { + return Math.min(100, Math.max(0, bindings + .filter((binding) => binding.stage === 'critical_chance' && binding.operator === 'add') + .reduce((total, binding) => + total + transformedBindingValue(Number(attributes[binding.attributeId] ?? 0), binding), 0))); +} + +function conditionalProgram( + rule: any, + variantKey: string, + valueScale = 1 +): MechanicProgram | null { + if (!rule?.rule) return null; + const adapted = adaptConditionalEffectProgram(rule, { variantKey }); + if (!adapted.program || adapted.status === 'INVALID' || adapted.status === 'CAPTURED_ONLY') return null; + if (valueScale === 1) return adapted.program; + const scale = (value: NumericExpression): NumericExpression => + typeof value === 'number' ? value * valueScale : value; + return { + ...adapted.program, + clauses: adapted.program.clauses.map((clause) => ({ + ...clause, + outcomes: clause.outcomes?.map((outcome) => ({ ...outcome, value: scale(outcome.value) })) + })) + }; +} + +function interpolatedConditionalProgram( + rule: any, + minimumVariant: string, + maximumVariant: string, + step: number, + totalSteps: number +): MechanicProgram | null { + const minimum = conditionalProgram(rule, minimumVariant); + const maximum = conditionalProgram(rule, maximumVariant); + if (!minimum || !maximum) return minimum ?? maximum; + const ratio = Math.max(0, Math.min(1, Number(step) / Math.max(1, Number(totalSteps)))); + const maximumByKey = new Map(maximum.clauses.flatMap((clause) => + (clause.outcomes ?? []).map((outcome) => [`${clause.id}:${outcome.id}`, outcome] as const) + )); + return { + ...minimum, + clauses: minimum.clauses.map((clause) => ({ + ...clause, + outcomes: clause.outcomes?.map((outcome) => { + const upper = maximumByKey.get(`${clause.id}:${outcome.id}`); + return upper && typeof outcome.value === 'number' && typeof upper.value === 'number' + ? { ...outcome, value: outcome.value + (upper.value - outcome.value) * ratio } + : outcome; + }) + })) + }; +} + +function equippedConditionalProgram(equipped: any): MechanicProgram | null { + if (String(equipped.key).startsWith('chips:')) { + return interpolatedConditionalProgram(equipped.rule, 'minimum', 'maximum', Number(equipped.tier), 3); + } + if (String(equipped.key).startsWith('protocol:')) { + return conditionalProgram(equipped.rule, 'base'); + } + return conditionalProgram( + equipped.rule, + String(equipped.key).startsWith('weapon:') || String(equipped.key).startsWith('gear:') + ? (Number(equipped.tier) >= 2 ? 'II' : 'I') + : 'base' + ); +} + +type ActiveMechanicEvaluation = { + status: 'OK'; + result: MechanicEvaluation; + program: MechanicProgram; + encounterSeconds: number; +}; + +function mechanicScenarioInputs(program: MechanicProgram, configured: unknown) { + const values = configured && typeof configured === 'object' && !Array.isArray(configured) + ? configured as Record + : {}; + return Object.fromEntries((program.scenarioInputs ?? []).map((input) => [ + input.key, + values[input.key] ?? input.defaultValue ?? (input.kind === 'toggle' ? false : 0) + ])); +} + +function evaluateConfiguredMechanic( + program: MechanicProgram, + events: MechanicEvent[], + state: any, + profile: LoadoutDraft['combatProfile'], + weaponBaseDamage: number, + maxHealth: number, + calculationMode: LoadoutDraft['calculationMode'], + equipmentContext = { protocolTalentUpgrades: 0 } +): ActiveMechanicEvaluation | null { + if (calculationMode !== 'maximum' && state.mode === 'off') return null; + const encounterSeconds = Math.max(1, Number(profile.encounterSeconds)); + const maximum = calculationMode === 'maximum' || state.mode === 'peak'; + const result = evaluateMechanicProgram( + program, + events, + maximum + ? { mode: 'maximum', encounterSeconds } + : { mode: 'custom', encounterSeconds, inputs: mechanicScenarioInputs(program, state.inputs) }, + { + maxHealth: Math.max(1, maxHealth), + currentHealth: Math.max(0, maxHealth * Number(profile.healthPercent) / 100), + weaponBaseDamage, + values: { extraHealth: profile.extraHealthActive ? 1 : 0 } + }, + equipmentContext + ); + return result.status === 'OK' ? { status: 'OK', result, program, encounterSeconds } : null; +} + +type ModifierInterval = { + start: number; + end: number; + value: number; + stateValue: number; + attributeId: string; + scope: string; + outcomeId: string; + phase: MechanicOutcome['phase']; + activationEvent: MechanicEvent['kind']; + endAtNextActivation: boolean; +}; + +function modifierIntervals(source: ActiveMechanicEvaluation): ModifierInterval[] { + const emissions = source.result.outcomes + .filter((emission) => emission.kind === 'attribute_modifier' && emission.attributeId) + .sort((left, right) => left.at - right.at); + return emissions.flatMap((emission, index) => { + const clause = source.program.clauses.find((candidate) => + candidate.outcomes?.some((outcome) => outcome.id === emission.outcomeId)); + const definition = clause?.outcomes?.find((candidate) => candidate.id === emission.outcomeId); + if (!clause || !definition) return []; + const next = emissions.slice(index + 1).find((candidate) => candidate.outcomeId === emission.outcomeId); + const pool = definition.statePool + ? source.program.pools?.find((candidate) => candidate.key === definition.statePool) + : null; + const naturalEnd = definition.phase === 'while_active' + ? emission.at + Number(pool?.expiresAfterSeconds ?? source.encounterSeconds) + : emission.at; + const end = Math.min(source.encounterSeconds, next?.at ?? naturalEnd, naturalEnd); + if ( + end <= emission.at + && definition.phase !== 'while_active' + && definition.phase !== 'on_event' + ) return []; + return [{ + start: emission.at, + end: Math.max(emission.at, end), + value: emission.appliedValue, + stateValue: emission.stateValue, + attributeId: String(emission.attributeId), + scope: emission.scope, + outcomeId: emission.outcomeId, + phase: definition.phase, + activationEvent: clause.when, + endAtNextActivation: next !== undefined && next.at <= naturalEnd + }]; + }); +} + +function averageModifierValues(source: ActiveMechanicEvaluation) { + const values = new Map(); + for (const interval of modifierIntervals(source)) { + const key = `${interval.attributeId}:${interval.scope}`; + const current = values.get(key) ?? { value: 0, scope: interval.scope }; + current.value += interval.value * (interval.end - interval.start) / source.encounterSeconds; + values.set(key, current); + } + return [...values].map(([key, value]) => ({ + attributeId: key.slice(0, key.lastIndexOf(':')), + scope: value.scope, + value: value.value + })); +} + +const TIMELINE_EVENT_ORDER: Readonly> = { + firing_started: 0, + shot_fired: 1, + weapon_hit: 2, + damage_dealt: 3, + firing_stopped: 4, + reload_started: 5, + reload_completed: 6, + weapon_swapped: 7, + enemy_killed: 8, + assist: 9, + skill_deployed: 10, + skill_hit: 11, + agent_hit: 12, + health_damage_taken: 13, + healing_applied: 14, + extra_health_granted: 15, + tick: 16, + proximity_snapshot: 17, + movement_snapshot: 18 +}; + +function intervalAppliesAt( + interval: ModifierInterval, + at: number, + eventKind: MechanicEvent['kind'] +) { + const epsilon = 0.000_001; + const queryOrder = TIMELINE_EVENT_ORDER[eventKind]; + const activationOrder = TIMELINE_EVENT_ORDER[interval.activationEvent]; + if (interval.phase === 'on_event') { + return Math.abs(at - interval.start) <= epsilon && activationOrder <= queryOrder; + } + if (interval.phase !== 'while_active') return false; + if (at < interval.start - epsilon || at > interval.end + epsilon) return false; + if (Math.abs(at - interval.start) <= epsilon && activationOrder >= queryOrder) return false; + if ( + Math.abs(at - interval.end) <= epsilon + && !interval.endAtNextActivation + ) { + return false; + } + return true; +} + +export function mechanicModifiersAt( + source: ActiveMechanicEvaluation, + at: number, + eventKind: MechanicEvent['kind'] = 'weapon_hit' +) { + const values = new Map(); + for (const interval of modifierIntervals(source)) { + if (!intervalAppliesAt(interval, at, eventKind)) continue; + const key = `${interval.attributeId}:${interval.scope}`; + const current = values.get(key) ?? { value: 0, scope: interval.scope }; + current.value += interval.value; + values.set(key, current); + } + return [...values].map(([key, value]) => ({ + attributeId: key.slice(0, key.lastIndexOf(':')), + scope: value.scope, + value: value.value + })); +} + +function mechanicSummary(source: ActiveMechanicEvaluation) { + const intervals = modifierIntervals(source); + const maxStacks = Math.max(1, ...(source.program.pools ?? []).map((pool) => Number(pool.max ?? 1))); + const maxStacksReached = Math.max(0, ...intervals.map((interval) => interval.stateValue)); + const covered = intervals.reduce((total, interval) => total + Math.max(0, interval.end - interval.start), 0); + const uptimePercent = Math.min(100, covered / source.encounterSeconds * 100); + const buildUp = source.result.outcomes.find((emission) => emission.stateValue >= maxStacks)?.at ?? null; + return { + averageStacks: intervals.reduce( + (total, interval) => total + interval.stateValue * (interval.end - interval.start) / source.encounterSeconds, + 0 + ), + maxStacksReached, + maxStacks, + stacksAtEnd: Math.max(0, ...source.result.finalState.map((state) => state.value)), + buildUpSeconds: buildUp, + maintained: uptimePercent >= 99.9 && maxStacksReached >= maxStacks, + uptimePercent, + activationCount: source.result.estimatedActivationCount, + instantTotal: source.result.outcomes + .filter((outcome) => outcome.kind !== 'direct_damage' && outcome.kind !== 'attribute_modifier') + .reduce((total, outcome) => total + outcome.appliedValue, 0) + }; +} + +export function buildWeaponMechanicTimeline( + weapon: any, + profile: LoadoutDraft['combatProfile'], + criticalChancePercent = 0 +): MechanicEvent[] { + const encounterSeconds = Math.max(0, Number(profile.encounterSeconds)); + const rpm = Math.max(1, Number(weapon?.rpm)); + const magazine = Math.max(1, Math.round(Number(weapon?.magazine))); + const reloadSeconds = Math.max(0, Number(weapon?.reload_speed)); + const shotsPerBurst = Math.max(1, Math.round(Number(profile.shotsPerBurst))); + const pauseSeconds = Math.max(0, Number(profile.burstPauseSeconds)); + const targetInterval = Number(profile.distinctTargetsPerMinute) > 0 + ? Math.min( + Math.max(0.1, Number(profile.sameTargetSeconds)), + 60 / Number(profile.distinctTargetsPerMinute) + ) + : Math.max(0.1, Number(profile.sameTargetSeconds)); + const events: MechanicEvent[] = [{ kind: 'firing_started', at: 0 }]; + let time = 0; + let magazineRemaining = magazine; + let burstRemaining = shotsPerBurst; + let hitAccumulator = 0; + let headshotAccumulator = 0; + let criticalAccumulator = 0; + const criticalChance = Math.max(0, Math.min(100, Number(criticalChancePercent))) / 100; + const shotInterval = 60 / rpm; + while (time <= encounterSeconds && events.length < 100_000) { + if (magazineRemaining <= 0) { + events.push({ kind: 'reload_started', at: time }); + time += reloadSeconds; + if (time <= encounterSeconds) { + events.push({ kind: 'reload_completed', at: time }); + events.push({ kind: 'firing_started', at: time }); + } + magazineRemaining = magazine; + burstRemaining = shotsPerBurst; + continue; + } + if (burstRemaining <= 0) { + if (pauseSeconds > 0) { + events.push({ kind: 'firing_stopped', at: time }); + time += pauseSeconds; + if (time <= encounterSeconds) events.push({ kind: 'firing_started', at: time }); + } + burstRemaining = shotsPerBurst; + continue; + } + hitAccumulator += Math.max(0, Math.min(100, Number(profile.hitRate))) / 100; + const hit = hitAccumulator >= 1; + if (hit) hitAccumulator -= 1; + if (hit) headshotAccumulator += Math.max(0, Math.min(100, Number(profile.headshotRate))) / 100; + const headshot = hit && headshotAccumulator >= 1; + if (headshot) headshotAccumulator -= 1; + if (hit) criticalAccumulator += criticalChance; + const critical = hit && criticalAccumulator >= 1; + if (critical) criticalAccumulator -= 1; + const targetId = `target-${Math.floor(time / targetInterval)}`; + events.push({ + kind: 'shot_fired', + at: time, + targetId, + values: { + magazineRemaining, + magazinePercent: magazineRemaining / magazine * 100, + distanceMeters: Number(profile.targetDistanceMeters) + } + }); + if (hit) { + const values = { + headshot, + critical, + distanceMeters: Number(profile.targetDistanceMeters) + }; + events.push({ kind: 'weapon_hit', at: time, targetId, values }); + events.push({ kind: 'damage_dealt', at: time, targetId, values: { ...values, source: 'weapon' } }); + } + magazineRemaining -= 1; + burstRemaining -= 1; + time += shotInterval; + } + events.push({ kind: 'firing_stopped', at: encounterSeconds }); + const intervalEvents = ( + kind: 'enemy_killed' | 'health_damage_taken' | 'weapon_swapped', + perMinute: number + ) => { + if (!(perMinute > 0)) return; + const interval = 60 / perMinute; + let headshotKillAccumulator = 0; + for (let at = interval; at <= encounterSeconds; at += interval) { + if (kind === 'enemy_killed') { + headshotKillAccumulator += Math.max(0, Math.min(100, Number(profile.headshotKillRate))) / 100; + const headshot = headshotKillAccumulator >= 1; + if (headshot) headshotKillAccumulator -= 1; + events.push({ + kind, + at, + targetId: `target-${Math.floor(at / targetInterval)}`, + values: { headshot, source: 'weapon' } + }); + } + else events.push({ kind, at }); + } + }; + intervalEvents('enemy_killed', Number(profile.killsPerMinute)); + intervalEvents('health_damage_taken', Number(profile.damageTakenPerMinute)); + intervalEvents('weapon_swapped', Number(profile.swapsPerMinute)); + for (let at = 0; at <= encounterSeconds; at += 1) { + events.push({ kind: 'tick', at }); + events.push({ + kind: 'proximity_snapshot', + at, + values: { + distanceMeters: Number(profile.targetDistanceMeters), + targetDistanceMeters: Number(profile.targetDistanceMeters), + enemyCount: 1, + allyCount: 0 + } + }); + events.push({ kind: 'movement_snapshot', at, values: { moving: false, sprinting: false } }); + } + return events.sort((left, right) => left.at - right.at); +} + +function effectAppliesToWeapon(scope: string, sourceKey: string, weaponIndex: number) { + if (scope === 'allies' || scope === 'assigned_ability' || scope === 'all_skills') return false; + if (scope !== 'equipped_weapon') return true; + const match = sourceKey.match(/^weapon:(\d+):/); + return !match || Number(match[1]) === weaponIndex; +} + +function effectAppliesToSkills(scope: string) { + return !['allies', 'equipped_weapon'].includes(scope); +} + +function skillModifierWindows( + draft: LoadoutDraft, + catalog: Catalog, + summary: LoadoutSummary, + allowedAttributeIds: Set +): SkillModifierWindow[] { + const focus = selectedFocus(draft, catalog); + const combatEffects = selectedNodeRanks(draft, catalog).flatMap(({ node, rank }: any) => + (rank?.combatEffects ?? []).map((effect: any) => ({ + ...effect, + nodeName: node.name, + abilityId: null + })) + ); + const sources = [ + ...summary.equippedTalents.map((equipped: any) => ({ + key: String(equipped.key), + abilityId: equipped.abilityId ? String(equipped.abilityId) : null, + equipmentContext: { + protocolTalentUpgrades: String(equipped.key).startsWith('protocol:') + ? Number(equipped.tier) + : 0 + }, + program: equippedConditionalProgram(equipped) + })), + ...combatEffects.map((effect: any) => ({ + key: String(effect.id), + abilityId: effect.abilityId, + equipmentContext: { protocolTalentUpgrades: 0 }, + program: conditionalProgram(effect.rule, 'base') + })) + ].filter((source) => source.program); + const primarySelection = draft.weapons[0]; + const primaryWeapon = catalog.weapons.find((weapon) => weapon.id === primarySelection.itemId); + const primaryBindings = calculationBindings(catalog, primaryWeapon); + const baseAttributes = Object.fromEntries(summary.stats.map((stat) => [stat.id, stat.value])); + const primaryCriticalChance = weaponCriticalChance(baseAttributes, primaryBindings); + const focusAbilityIds = new Set((focus?.abilities ?? []).map((ability: any) => String(ability.id))); + const windows: SkillModifierWindow[] = []; + const maxHealthId = catalog.attributes.find((item) => item.name === 'Health')?.id; + const maxHealth = summary.stats.find((stat) => stat.id === maxHealthId)?.value ?? 1; + const evaluatedSources = sources.flatMap((source) => { + const state = draft.conditionalEffects[source.key] ?? { mode: 'off' }; + const profile = { ...draft.combatProfile, ...(state.overrides ?? {}) }; + const evaluation = evaluateConfiguredMechanic( + source.program!, + primaryWeapon ? buildWeaponMechanicTimeline(primaryWeapon, profile, primaryCriticalChance) : [], + state, + profile, + Number(primarySelection.damage ?? 0), + maxHealth, + draft.calculationMode, + source.equipmentContext + ); + return evaluation ? [{ ...source, evaluation }] : []; + }); + + for (const source of evaluatedSources) { + for (const interval of modifierIntervals(source.evaluation)) { + if (!effectAppliesToSkills(interval.scope) || !allowedAttributeIds.has(interval.attributeId)) continue; + const abilityIds = interval.scope === 'assigned_ability' + ? (source.abilityId && focusAbilityIds.has(source.abilityId) ? [source.abilityId] : []) + : undefined; + if (interval.scope === 'assigned_ability' && !abilityIds?.length) continue; + windows.push({ + startSeconds: interval.start, + endSeconds: interval.end, + attributes: { [interval.attributeId]: interval.value }, + abilityIds + }); + } + } + + return windows; +} + export function evaluateLoadoutCombat( draft: LoadoutDraft, catalog: Catalog, @@ -307,6 +851,24 @@ export function evaluateLoadoutCombat( const combatEffects = selectedNodeRanks(draft, catalog).flatMap(({ node, rank }: any) => (rank?.combatEffects ?? []).map((effect: any) => ({ ...effect, nodeName: node.name })) ); + const conditionalSources = [ + ...summary.equippedTalents.map((equipped: any) => ({ + key: equipped.key, + name: equipped.custom?.name ?? catalog.talents.find((talent) => talent.id === equipped.id)?.name ?? 'Talent', + equipmentContext: { + protocolTalentUpgrades: String(equipped.key).startsWith('protocol:') + ? Number(equipped.tier) + : 0 + }, + program: equippedConditionalProgram(equipped) + })), + ...combatEffects.map((effect: any) => ({ + key: String(effect.id), + name: String(effect.definition?.name ?? effect.nodeName ?? 'Specialization effect'), + equipmentContext: { protocolTalentUpgrades: 0 }, + program: conditionalProgram(effect.rule, 'base') + })) + ].filter((source) => source.program); const required = (weaponIndex: number) => { const missing: string[] = []; if (!catalog.calculation.revision) missing.push('An active ISAC calculation revision'); @@ -337,29 +899,79 @@ export function evaluateLoadoutCombat( }; } const bindings = calculationBindings(catalog, weapon); - const effects = combatEffects.map((effect: any) => ({ - id: String(effect.id), - trigger: { - hitsRequired: Number(effect.trigger_count), - windowSeconds: Number(effect.trigger_window_seconds) - }, - lifecycle: { - maxStacks: Number(effect.max_stacks), - durationSeconds: Number(effect.duration_seconds), - stacksPerTrigger: Number(effect.stacks_per_trigger) - }, - attributes: Object.fromEntries(effect.modifiers.map((modifier: any) => [ - String(modifier.stat_attribute_id), - Number(modifier.value_per_stack) - ])) - })); - const perEffect = Object.fromEntries(combatEffects.map((effect: any) => { - const state = draft.specialization.effectStates[effect.id] ?? { mode: 'off' }; - return [effect.id, { - mode: state.mode, - ...(state.overrides ?? {}) - }]; - })); + const baseAttributes = Object.fromEntries(summary.stats.map((stat) => [stat.id, stat.value])); + const maxHealthId = catalog.attributes.find((item) => item.name === 'Health')?.id; + const maxHealth = Number(baseAttributes[maxHealthId] ?? 1); + const evaluateSources = ( + timelineWeapon: any, + criticalAttributes: Record + ) => conditionalSources.flatMap((source) => { + const state = draft.conditionalEffects[source.key] ?? { mode: 'off' }; + const profile = { ...draft.combatProfile, ...(state.overrides ?? {}) }; + const evaluation = evaluateConfiguredMechanic( + source.program!, + buildWeaponMechanicTimeline( + timelineWeapon, + profile, + weaponCriticalChance(criticalAttributes, bindings) + ), + state, + profile, + Number(selection.damage), + maxHealth, + draft.calculationMode, + source.equipmentContext + ); + return evaluation ? [{ ...source, state, evaluation }] : []; + }); + const attributesFrom = (evaluations: ReturnType) => { + const values = { ...baseAttributes }; + for (const source of evaluations) { + for (const modifier of averageModifierValues(source.evaluation)) { + if (!effectAppliesToWeapon(modifier.scope, source.key, weaponIndex)) continue; + values[modifier.attributeId] = applyAttributeValue( + catalog, + values[modifier.attributeId] ?? 0, + modifier.attributeId, + modifier.value + ); + } + } + return values; + }; + let timelineWeapon = { ...weapon }; + let evaluations = evaluateSources(timelineWeapon, baseAttributes); + for (let pass = 0; pass < 3; pass += 1) { + const currentAttributes = attributesFrom(evaluations); + const cycle = resolveWeaponFiringCycle( + { + rpm: Number(weapon?.rpm), + magazine: Number(weapon?.magazine), + reloadSeconds: Number(weapon?.reload_speed) + }, + currentAttributes, + bindings + ); + timelineWeapon = { + ...weapon, + rpm: cycle.rpm, + magazine: cycle.magazine, + reload_speed: cycle.reloadSeconds + }; + evaluations = evaluateSources(timelineWeapon, currentAttributes); + } + const modeledAttributes = attributesFrom(evaluations); + let directDamageTotal = 0; + for (const source of evaluations) { + for (const outcome of source.evaluation.result.outcomes) { + if (outcome.kind !== 'direct_damage' + || !effectAppliesToWeapon(outcome.scope, source.key, weaponIndex)) continue; + directDamageTotal += outcome.appliedValue; + } + } + const calculationStages = catalog.calculation.stages + .map((stage) => calculationStage(catalog, String(stage.stage_key))) + .filter((stage): stage is CalculationStage => stage !== null); const result = calculateCombat({ weapon: { baseDamage: selection.damage, @@ -368,16 +980,15 @@ export function evaluateLoadoutCombat( reloadSeconds: Number(weapon?.reload_speed), headshotMultiplier: 2 }, - attributes: Object.fromEntries(summary.stats.map((stat) => [stat.id, stat.value])), + attributes: modeledAttributes, bindings, - stageOrder: catalog.calculation.stages - .map((stage) => calculationStage(catalog, String(stage.stage_key))) - .filter((stage): stage is CalculationStage => stage !== null), - stackingEffects: effects, + stageOrder: calculationStages, assumptions: { mode: 'off', - ...draft.specialization.combatProfile, - perEffect + hitRate: draft.combatProfile.hitRate, + headshotRate: draft.combatProfile.headshotRate, + encounterSeconds: draft.combatProfile.encounterSeconds, + firingActivity: 100, } }); if (result.status === 'INSUFFICIENT_DATA') { @@ -393,6 +1004,40 @@ export function evaluateLoadoutCombat( revisionLabel: `RULES R${catalog.calculation.revision.version}` }; } + const timeline = buildWeaponMechanicTimeline( + timelineWeapon, + draft.combatProfile, + weaponCriticalChance(modeledAttributes, bindings) + ); + let encounterWeaponDamage = 0; + for (const event of timeline) { + if (event.kind !== 'weapon_hit') continue; + const attributesAtShot = { ...baseAttributes }; + for (const source of evaluations) { + for (const modifier of mechanicModifiersAt(source.evaluation, event.at, event.kind)) { + if (!effectAppliesToWeapon(modifier.scope, source.key, weaponIndex)) continue; + attributesAtShot[modifier.attributeId] = applyAttributeValue( + catalog, + attributesAtShot[modifier.attributeId] ?? 0, + modifier.attributeId, + modifier.value + ); + } + } + encounterWeaponDamage += expectedWeaponShotDamage( + { + baseDamage: selection.damage, + rpm: Number(weapon?.rpm), + magazine: Number(weapon?.magazine), + reloadSeconds: Number(weapon?.reload_speed), + headshotMultiplier: 2 + }, + attributesAtShot, + bindings, + event.values?.headshot === true, + calculationStages + ); + } return { weaponName: weapon.name, weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON', @@ -403,22 +1048,107 @@ export function evaluateLoadoutCombat( criticalHeadshot: result.hitValues?.criticalHeadshot, burstDps: result.burstDps ?? undefined, sustainedDps: result.sustainedDps ?? undefined, - encounterDps: result.encounterDps ?? undefined, - effectSummaries: result.stackSummaries.map((stack) => { - const effect = combatEffects.find((item: any) => String(item.id) === stack.effectId); - return { - id: stack.effectId, - name: String(effect?.definition?.name ?? effect?.nodeName ?? 'Conditional effect'), - mode: stack.mode, - averageStacks: stack.averageStacks, - maxStacksReached: stack.maxStacksReached, - maxStacks: Number(effect?.max_stacks ?? 0), - stacksAtEnd: stack.stacksAtEnd, - buildUpSeconds: stack.buildUpSeconds, - maintained: stack.maintained - }; - }), + encounterDps: (encounterWeaponDamage + directDamageTotal) + / Math.max(1, draft.combatProfile.encounterSeconds), + effectSummaries: evaluations.map((source) => ({ + id: source.key, + name: source.name, + mode: source.state.mode, + ...mechanicSummary(source.evaluation) + })), revisionLabel: `RULES R${catalog.calculation.revision.version}` }; }); } + +export function evaluateLoadoutSkillCombat( + draft: LoadoutDraft, + catalog: Catalog, + summary = summarizeLoadout(draft, catalog) +): SkillCalculation { + const focus = selectedFocus(draft, catalog); + const revisionId = String(catalog.calculation.revision?.id ?? ''); + const supportedStages = new Set([ + 'flat_damage', + 'skill_damage', + 'final_damage', + 'critical_chance', + 'critical_damage', + 'multishot_chance', + 'multishot_damage', + 'cooldown_recovery', + 'duration' + ]); + const bindings: SkillAttributeBinding[] = (catalog.calculation.skillProfiles ?? []) + .flatMap((profile: any) => { + const stage = profile.stage_key === 'engineering' ? 'skill_damage' : String(profile.stage_key); + return supportedStages.has(stage) + ? [{ + attributeId: String(profile.stat_attribute_id), + stage: stage as SkillAttributeBinding['stage'], + operator: profile.operator === 'multiply' ? 'multiply' as const : 'add' as const, + weight: Number(profile.weight ?? 1) + }] + : []; + }); + const boundIds = new Set(bindings.map((binding) => binding.attributeId)); + const attributes = Object.fromEntries( + summary.stats.filter((stat) => boundIds.has(stat.id)).map((stat) => [stat.id, stat.value]) + ); + const abilities = (focus?.abilities ?? []).map((ability: any) => { + const attack = ability.attack; + const selected = draft.skillActivations[ability.id] ?? { mode: 'off', targets: 1 }; + const activation = selected.mode === 'custom_per_minute' + ? { mode: 'custom_per_minute' as const, usesPerMinute: Number(selected.usesPerMinute ?? 1) } + : selected.mode === 'on_cooldown' + ? { mode: 'on_cooldown' as const } + : { mode: 'off' as const }; + const chargePolicy = attack?.charge_policy === 'independent' + ? { type: 'independent' as const, charges: Number(attack.charge_count) } + : attack?.charge_policy === 'full_set' + ? { type: 'full_set' as const, charges: Number(attack.charge_count) } + : { type: 'none' as const }; + return { + id: String(ability.id), + baseDamage: attack?.base_damage === undefined ? undefined : Number(attack.base_damage), + damageType: attack?.damage_type, + delivery: attack?.delivery === 'periodic' + ? { + type: 'periodic' as const, + ticks: Math.max(1, Math.floor(Number(attack.damage_duration_seconds) / Number(attack.tick_interval_seconds))), + durationSeconds: Number(attack.damage_duration_seconds), + hitsPerTick: Number(attack.hits_per_activation ?? 1), + intervalSeconds: Number(attack.tick_interval_seconds), + firstTickDelaySeconds: Number(attack.tick_interval_seconds) + } + : { type: 'direct' as const, hits: Number(attack?.hits_per_activation ?? 1) }, + castSeconds: Number(attack?.cast_seconds ?? 0), + cooldownSeconds: Number(ability.cooldown_seconds ?? 0), + targetCap: Number(attack?.target_cap ?? 1), + targetCount: Number(selected.targets ?? 1), + chargePolicy, + activation + }; + }); + return calculateSkillDps({ + rules: { revisionId, bindings }, + abilities, + attributes, + attributeRules: Object.fromEntries([...boundIds].map((id) => { + const stat = attribute(catalog, id); + return [id, { + combination: stat?.calculation_type === 'Multiplicative' && stat?.unit === '%' + ? 'multiplicative_percent' as const + : 'additive' as const, + cap: stat?.cap_value === null || stat?.cap_value === undefined + ? null + : Number(stat.cap_value) + }]; + })), + modifierWindows: skillModifierWindows(draft, catalog, summary, boundIds), + assumptions: { + encounterSeconds: draft.combatProfile.encounterSeconds, + targetCount: Math.max(1, ...Object.values(draft.skillActivations).map((selection) => Number(selection.targets ?? 1))) + } + }); +} diff --git a/src/lib/builder/loadout.test.ts b/src/lib/builder/loadout.test.ts index f1486aa..cbfd852 100644 --- a/src/lib/builder/loadout.test.ts +++ b/src/lib/builder/loadout.test.ts @@ -9,6 +9,7 @@ describe('parseLoadoutDraft', () => { const parsed = parseLoadoutDraft(draft); expect(parsed).toEqual(draft); + expect(parsed?.calculationMode).toBe('maximum'); expect(parsed).not.toBe(draft); parsed!.gear.Holster.level = 1; expect(draft.gear.Holster.level).toBe(50); @@ -22,20 +23,46 @@ describe('parseLoadoutDraft', () => { expect(parseLoadoutDraft(retired)).toBeNull(); }); - it('rejects a version 2 draft that does not contain exactly nine chips', () => { + it('rejects a version 4 draft that does not contain exactly nine chips', () => { const draft = copyDraft(); draft.chips.pop(); expect(parseLoadoutDraft(draft)).toBeNull(); }); - it('rejects a specialization missing a required current field', () => { - const draft = copyDraft() as unknown as { specialization: Record }; - delete draft.specialization.combatProfile; + it('rejects a draft missing the unified combat profile', () => { + const draft = copyDraft() as unknown as Record; + delete draft.combatProfile; expect(parseLoadoutDraft(draft)).toBeNull(); }); + it('accepts a custom scenario and preserves its assumptions', () => { + const draft = copyDraft(); + draft.calculationMode = 'custom'; + draft.combatProfile.hitRate = 63; + draft.combatProfile.targetDistanceMeters = 28; + draft.conditionalEffects.relentless = { + mode: 'encounter', + inputs: { + alliesWithinRange: 3, + targetHasDebuff: true + } + }; + + expect(parseLoadoutDraft(draft)).toEqual(draft); + }); + + it('rejects drafts without the required calculation model', () => { + const missing = copyDraft() as unknown as Record; + delete missing.calculationMode; + expect(parseLoadoutDraft(missing)).toBeNull(); + + const invalid = copyDraft() as unknown as Record; + invalid.calculationMode = 'legacy'; + expect(parseLoadoutDraft(invalid)).toBeNull(); + }); + it('rejects a specialization that carries retired unassigned chips', () => { const draft = copyDraft() as unknown as { specialization: Record }; draft.specialization.unassignedChips = []; diff --git a/src/lib/builder/loadout.ts b/src/lib/builder/loadout.ts index 054d80a..e465386 100644 --- a/src/lib/builder/loadout.ts +++ b/src/lib/builder/loadout.ts @@ -3,23 +3,49 @@ export type GearSelection = { itemId: string; level: number; brandId: string; ta export type WeaponSelection = { itemId: string; level: number; tier: number; damage: number; firepower: number; toughness: number; engineering: number; talent1Id: string; talent2Id: string; rolls: [Roll, Roll] }; export type ChipSelection = { setId: string; quality: 'Superior' | 'High-end'; primary: Roll; secondary: [Roll, Roll] }; export type ProtocolSelection = { itemId: string; level: number; talentUpgrades: number }; -export type CombatProfile = { encounterSeconds: number; hitRate: number; headshotRate: number; firingActivity: number }; +export type CombatProfile = { + encounterSeconds: number; + hitRate: number; + headshotRate: number; + shotsPerBurst: number; + burstPauseSeconds: number; + sameTargetSeconds: number; + distinctTargetsPerMinute: number; + killsPerMinute: number; + headshotKillRate: number; + damageTakenPerMinute: number; + swapsPerMinute: number; + targetDistanceMeters: number; + healthPercent: number; + extraHealthActive: boolean; +}; +export type CalculationMode = 'maximum' | 'custom'; export type EffectMode = 'off' | 'peak' | 'encounter'; -export type ConditionalEffectSelection = { mode: EffectMode; overrides?: Partial }; +export type ConditionalEffectSelection = { + mode: EffectMode; + overrides?: Partial; + inputs?: Record; +}; +export type SkillActivationSelection = { + mode: 'off' | 'on_cooldown' | 'custom_per_minute'; + usesPerMinute?: number; + targets: number; +}; export type SpecializationSelection = { focusId: string; nodeRanks: Record; - effectStates: Record; - combatProfile: CombatProfile; }; export type LoadoutDraft = { - version: 2; + version: 4; buildName: string; + calculationMode: CalculationMode; gear: Record; weapons: [WeaponSelection, WeaponSelection]; chips: [ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection]; protocol: ProtocolSelection; - activeTalents: string[]; + conditionalEffects: Record; + combatProfile: CombatProfile; + skillActivations: Record; specialization: SpecializationSelection; }; @@ -28,20 +54,36 @@ export const blankRoll = (): Roll => ({ statId: '', value: 0 }); export const blankGear = (): GearSelection => ({ itemId: '', level: 50, brandId: '', talentId: '', health: 0, armor: 0, firepower: 0, toughness: 0, engineering: 0, rolls: [blankRoll(), blankRoll()], augmentSlots: [] }); export const blankWeapon = (): WeaponSelection => ({ itemId: '', level: 50, tier: 0, damage: 0, firepower: 0, toughness: 0, engineering: 0, talent1Id: '', talent2Id: '', rolls: [blankRoll(), blankRoll()] }); export const blankChip = (): ChipSelection => ({ setId: '', quality: 'Superior', primary: blankRoll(), secondary: [blankRoll(), blankRoll()] }); -export const defaultCombatProfile = (): CombatProfile => ({ encounterSeconds: 60, hitRate: 75, headshotRate: 20, firingActivity: 80 }); +export const defaultCombatProfile = (): CombatProfile => ({ + encounterSeconds: 60, + hitRate: 75, + headshotRate: 20, + shotsPerBurst: 30, + burstPauseSeconds: 0, + sameTargetSeconds: 5, + distinctTargetsPerMinute: 12, + killsPerMinute: 4, + headshotKillRate: 20, + damageTakenPerMinute: 6, + swapsPerMinute: 2, + targetDistanceMeters: 15, + healthPercent: 100, + extraHealthActive: false +}); export const createDraft = (): LoadoutDraft => ({ - version: 2, + version: 4, buildName: 'Untitled loadout', + calculationMode: 'maximum', gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])), weapons: [blankWeapon(), blankWeapon()], chips: [blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip()], protocol: { itemId: '', level: 1, talentUpgrades: 0 }, - activeTalents: [], + conditionalEffects: {}, + combatProfile: defaultCombatProfile(), + skillActivations: {}, specialization: { focusId: '', - nodeRanks: {}, - effectStates: {}, - combatProfile: defaultCombatProfile() + nodeRanks: {} } }); @@ -88,39 +130,70 @@ const isProtocolSelection = (value: unknown): value is ProtocolSelection => && isFiniteNumber(value.talentUpgrades); const isCombatProfile = (value: unknown): value is CombatProfile => isRecord(value) - && hasExactKeys(value, ['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity']) - && ['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity'].every((key) => isFiniteNumber(value[key])); + && hasExactKeys(value, [ + 'encounterSeconds', 'hitRate', 'headshotRate', 'shotsPerBurst', 'burstPauseSeconds', + 'sameTargetSeconds', 'distinctTargetsPerMinute', 'killsPerMinute', 'headshotKillRate', 'damageTakenPerMinute', + 'swapsPerMinute', 'targetDistanceMeters', 'healthPercent', 'extraHealthActive' + ]) + && [ + 'encounterSeconds', 'hitRate', 'headshotRate', 'shotsPerBurst', 'burstPauseSeconds', + 'sameTargetSeconds', 'distinctTargetsPerMinute', 'killsPerMinute', 'headshotKillRate', 'damageTakenPerMinute', + 'swapsPerMinute', 'targetDistanceMeters', 'healthPercent' + ].every((key) => isFiniteNumber(value[key])) + && typeof value.extraHealthActive === 'boolean'; const isEffectStates = (value: unknown): value is Record => isRecord(value) && Object.values(value).every((entry) => { if (!isRecord(entry) - || !hasExactKeys(entry, entry.overrides === undefined ? ['mode'] : ['mode', 'overrides']) || !['off', 'peak', 'encounter'].includes(String(entry.mode))) return false; + const allowed = new Set(['mode', 'overrides', 'inputs']); + if (Object.keys(entry).some((key) => !allowed.has(key)) + || !['off', 'peak', 'encounter'].includes(String(entry.mode))) return false; + if (entry.inputs !== undefined && ( + !isRecord(entry.inputs) + || Object.values(entry.inputs).some((input) => + typeof input !== 'boolean' && !isFiniteNumber(input) + ) + )) return false; return entry.overrides === undefined || ( isRecord(entry.overrides) && Object.entries(entry.overrides).every(([key, override]) => - ['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity'].includes(key) && isFiniteNumber(override) + key === 'extraHealthActive' + ? typeof override === 'boolean' + : key in defaultCombatProfile() && isFiniteNumber(override) ) ); }); +const isSkillActivations = (value: unknown): value is Record => + isRecord(value) && Object.values(value).every((entry) => + isRecord(entry) + && ['off', 'on_cooldown', 'custom_per_minute'].includes(String(entry.mode)) + && isFiniteNumber(entry.targets) + && (entry.usesPerMinute === undefined || isFiniteNumber(entry.usesPerMinute)) + && Object.keys(entry).every((key) => ['mode', 'usesPerMinute', 'targets'].includes(key)) + ); export function parseLoadoutDraft(value: unknown): LoadoutDraft | null { if (!isRecord(value) - || !hasExactKeys(value, ['version', 'buildName', 'gear', 'weapons', 'chips', 'protocol', 'activeTalents', 'specialization']) - || value.version !== 2 - || typeof value.buildName !== 'string') return null; + || !hasExactKeys(value, [ + 'version', 'buildName', 'calculationMode', 'gear', 'weapons', 'chips', 'protocol', + 'conditionalEffects', 'combatProfile', 'skillActivations', 'specialization' + ]) + || value.version !== 4 + || typeof value.buildName !== 'string' + || !['maximum', 'custom'].includes(String(value.calculationMode))) return null; const gear = value.gear; if (!isRecord(gear) || Object.keys(gear).length !== gearSlots.length || !gearSlots.every((slot) => isGearSelection(gear[slot]))) return null; if (!Array.isArray(value.weapons) || value.weapons.length !== 2 || !value.weapons.every(isWeaponSelection)) return null; if (!Array.isArray(value.chips) || value.chips.length !== 9 || !value.chips.every(isChipSelection)) return null; - if (!isProtocolSelection(value.protocol) || !Array.isArray(value.activeTalents) - || !value.activeTalents.every((talent) => typeof talent === 'string')) return null; + if (!isProtocolSelection(value.protocol) + || !isEffectStates(value.conditionalEffects) + || !isCombatProfile(value.combatProfile) + || !isSkillActivations(value.skillActivations)) return null; if (!isRecord(value.specialization) - || !hasExactKeys(value.specialization, ['focusId', 'nodeRanks', 'effectStates', 'combatProfile']) + || !hasExactKeys(value.specialization, ['focusId', 'nodeRanks']) || typeof value.specialization.focusId !== 'string' - || !isNumberRecord(value.specialization.nodeRanks) - || !isEffectStates(value.specialization.effectStates) - || !isCombatProfile(value.specialization.combatProfile)) return null; + || !isNumberRecord(value.specialization.nodeRanks)) return null; return structuredClone(value) as LoadoutDraft; } diff --git a/src/lib/calculations/calculation-script.test.ts b/src/lib/calculations/calculation-script.test.ts new file mode 100644 index 0000000..8aeb3ee --- /dev/null +++ b/src/lib/calculations/calculation-script.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { + formatItemDamageRangeScript, + formatWeaponCalculationScript +} from './calculation-script'; + +describe('formatWeaponCalculationScript', () => { + it('starts from the selected equipped damage and explains ordered typed stages', () => { + const lines = formatWeaponCalculationScript({ + revision: { version: 4, name: 'Resurgence model' }, + stages: [ + { stage_key: 'base', label: 'Base damage', operator: 'base_damage', display_order: 1 }, + { stage_key: 'flat', label: 'Flat damage', operator: 'flat_add', display_order: 2 }, + { stage_key: 'cycle', label: 'Firing cycle', operator: 'firing_cycle', display_order: 3 } + ], + profiles: [{ + stat_attribute_id: 'weapon-flat', + stage_key: 'flat', + weight: 1.5, + soft_cap: null, + hard_cap: 100, + curve_type: 'none', + curve_parameter: null, + weapon_category: 'SMG' + }], + attributes: [{ id: 'weapon-flat', name: 'Flat Weapon Damage', unit: null }] + }); + expect(lines).toContain('TAKE equipped_weapon.damage AS base_damage'); + expect(lines).toContain('ADD flat_weapon_damage × 1.5 HARD_CAP 100 FOR SMG TO damage'); + expect(lines.at(-1)).toBe('RETURN expected_damage_per_second'); + }); + + it('keeps level scaling in item configuration instead of applying it twice', () => { + const lines = formatItemDamageRangeScript(); + expect(lines).toContain('SCALE configured_damage_range TO equipped_weapon.level'); + expect(lines).toContain('PASS equipped_weapon.damage TO combat_calculation'); + expect(lines).not.toContain('SCALE equipped_weapon.damage TO equipped_weapon.level'); + }); +}); diff --git a/src/lib/calculations/calculation-script.ts b/src/lib/calculations/calculation-script.ts new file mode 100644 index 0000000..ff52e18 --- /dev/null +++ b/src/lib/calculations/calculation-script.ts @@ -0,0 +1,131 @@ +export type CalculationScriptStage = { + stage_key: string; + label: string; + operator: string; + display_order: number; +}; + +export type CalculationScriptProfile = { + stat_attribute_id: string; + stage_key: string; + weight?: number | null; + soft_cap?: number | null; + hard_cap?: number | null; + curve_type?: string | null; + curve_parameter?: number | null; + weapon_category?: string | null; +}; + +export type CalculationScriptAttribute = { + id: string; + name: string; + unit?: string | null; +}; + +export type CalculationScriptRevision = { + version?: number | null; + name?: string | null; +}; + +const number = (value: unknown) => + Number.isInteger(Number(value)) ? String(Number(value)) : String(Number(Number(value).toFixed(3))); + +const identifier = (value: unknown) => String(value ?? '') + .normalize('NFKC') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') || 'unnamed'; + +function profileExpression( + profile: CalculationScriptProfile, + attributes: Map +) { + const attribute = attributes.get(profile.stat_attribute_id); + const unit = attribute?.unit ? ` ${attribute.unit}` : ''; + const weight = Number(profile.weight ?? 1); + let expression = `${identifier(attribute?.name ?? profile.stat_attribute_id)}${unit}`; + if (weight !== 1) expression += ` × ${number(weight)}`; + if (profile.soft_cap !== null && profile.soft_cap !== undefined) { + const curve = profile.curve_type && profile.curve_type !== 'none' + ? ` USING ${String(profile.curve_type).toUpperCase()}(${number(profile.curve_parameter ?? 1)})` + : ''; + expression += ` SOFT_CAP ${number(profile.soft_cap)}${curve}`; + } + if (profile.hard_cap !== null && profile.hard_cap !== undefined) { + expression += ` HARD_CAP ${number(profile.hard_cap)}`; + } + if (profile.weapon_category && profile.weapon_category !== '*') { + expression += ` FOR ${String(profile.weapon_category).toUpperCase()}`; + } + return expression; +} + +const operatorStatement = (operator: string, label: string, sources: string[]) => { + const source = sources.length ? sources.join(', ') : 'no_bound_attributes'; + switch (operator) { + case 'base_damage': + return ['TAKE equipped_weapon.damage AS base_damage']; + case 'flat_add': + return [`ADD ${source} TO damage`]; + case 'additive_percent': + return [`APPLY SUM(${source}) AS additive_percent TO damage`]; + case 'multiplicative_percent': + return [`APPLY EACH(${source}) AS multiplicative_percent TO damage`]; + case 'expected_critical': + return [ + `WEIGHT critical_damage BY critical_chance`, + ...(sources.length ? [`SOURCE ${source}`] : []) + ]; + case 'expected_headshot': + return [ + `WEIGHT headshot_damage BY headshot_chance`, + ...(sources.length ? [`SOURCE ${source}`] : []) + ]; + case 'rate_of_fire': + return [`ADJUST equipped_weapon.rpm USING ${source}`]; + case 'magazine_size': + return [`ADJUST equipped_weapon.magazine USING ${source}`]; + case 'reload_speed': + return [`ADJUST equipped_weapon.reload_seconds USING ${source}`]; + case 'firing_cycle': + return [ + 'CALCULATE firing_cycle FROM rpm, magazine, reload_seconds', + 'RETURN expected_damage_per_second' + ]; + default: + return [`APPLY ${identifier(label)} USING ${source}`]; + } +}; + +export function formatWeaponCalculationScript(input: { + revision?: CalculationScriptRevision | null; + stages: CalculationScriptStage[]; + profiles: CalculationScriptProfile[]; + attributes: CalculationScriptAttribute[]; +}) { + const attributes = new Map(input.attributes.map((attribute) => [String(attribute.id), attribute])); + const lines = [ + `CALCULATE expected_weapon_dps${input.revision?.version ? ` WITH rules_r${input.revision.version}` : ''}` + ]; + for (const stage of [...input.stages].sort( + (left, right) => Number(left.display_order) - Number(right.display_order) + )) { + const sources = input.profiles + .filter((profile) => profile.stage_key === stage.stage_key) + .map((profile) => profileExpression(profile, attributes)); + lines.push('', `STAGE ${String(stage.label).toUpperCase()}`); + lines.push(...operatorStatement(String(stage.operator), String(stage.label), sources)); + } + return lines; +} + +export function formatItemDamageRangeScript() { + return [ + 'CONFIGURE equipped_weapon', + 'READ configured_damage_range AT level_1', + 'SCALE configured_damage_range TO equipped_weapon.level', + 'SELECT equipped_weapon.damage WITHIN scaled_damage_range', + 'PASS equipped_weapon.damage TO combat_calculation' + ]; +} diff --git a/src/lib/calculations/combat.test.ts b/src/lib/calculations/combat.test.ts index a43646b..c9430ae 100644 --- a/src/lib/calculations/combat.test.ts +++ b/src/lib/calculations/combat.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { calculateCombat, type CombatCalculationInput } from './combat.js'; +import { + calculateCombat, + expectedWeaponShotDamage, + type CombatCalculationInput +} from './combat.js'; const base = (overrides: Partial = {}): CombatCalculationInput => ({ weapon: { baseDamage: 100, rpm: 600, magazine: 10, reloadSeconds: 2, headshotMultiplier: 2 }, @@ -24,6 +28,18 @@ describe('calculateCombat', () => { expect(result.sustainedDps).toBeCloseTo(495); }); + it('calculates expected body and headshot damage for timeline events', () => { + const input = base({ + attributes: { critChance: 50, critDamage: 100 }, + bindings: [ + { attributeId: 'critChance', stage: 'critical_chance', operator: 'add' }, + { attributeId: 'critDamage', stage: 'critical_damage', operator: 'add' } + ] + }); + expect(expectedWeaponShotDamage(input.weapon, input.attributes, input.bindings)).toBe(150); + expect(expectedWeaponShotDamage(input.weapon, input.attributes, input.bindings, true)).toBe(300); + }); + it('applies weights, hard caps, and a configured curve safely', () => { const result = calculateCombat(base({ attributes: { weapon: 100 }, @@ -41,6 +57,20 @@ describe('calculateCombat', () => { expect(result.hitValues?.normal).toBe(180); }); + it('applies firing-cycle attributes to burst and sustained DPS', () => { + const result = calculateCombat(base({ + attributes: { rate: 20, magazine: 100, reload: 100 }, + bindings: [ + { attributeId: 'rate', stage: 'rate_of_fire', operator: 'add' }, + { attributeId: 'magazine', stage: 'magazine_size', operator: 'add' }, + { attributeId: 'reload', stage: 'reload_speed', operator: 'add' } + ] + })); + expect(result.status).toBe('OK'); + expect(result.burstDps).toBe(1200); + expect(result.sustainedDps).toBeCloseTo(750); + }); + it('keeps effects off, applies peak stacks, and honors per-effect overrides', () => { const stackingEffects = [{ id: 'relentless', diff --git a/src/lib/calculations/combat.ts b/src/lib/calculations/combat.ts index e7b7921..645df2d 100644 --- a/src/lib/calculations/combat.ts +++ b/src/lib/calculations/combat.ts @@ -5,6 +5,9 @@ export type CalculationStage = | 'critical_damage' | 'headshot_chance' | 'headshot_damage' + | 'rate_of_fire' + | 'magazine_size' + | 'reload_speed' | 'final_damage'; export type CalculationOperator = 'add' | 'multiply'; export type CurveType = 'none' | 'power' | 'smoothstep'; @@ -26,6 +29,11 @@ export type WeaponCombatInput = { reloadSeconds?: number; headshotMultiplier?: number; }; +export type WeaponFiringCycle = { + rpm: number; + magazine: number; + reloadSeconds: number; +}; export type StackingMode = 'off' | 'peak' | 'encounter'; export type TriggerRule = { hitsRequired: number; windowSeconds: number }; export type StackLifecycle = { maxStacks: number; durationSeconds: number; stacksPerTrigger?: number }; @@ -244,11 +252,63 @@ function dpsFor( + values.critical * boundCriticalChance * (1 - headshotChance) + values.headshot * (1 - boundCriticalChance) * headshotChance + values.criticalHeadshot * boundCriticalChance * headshotChance; - const shotsPerSecond = weapon.rpm / 60; + const { rpm, magazine, reloadSeconds } = resolveWeaponFiringCycle( + weapon, + attributes, + bindings + ); + const shotsPerSecond = rpm / 60; return { values, burst: average * shotsPerSecond, - sustained: average * weapon.magazine / (weapon.magazine / shotsPerSecond + weapon.reloadSeconds) + sustained: average * magazine / (magazine / shotsPerSecond + reloadSeconds) + }; +} + +export function expectedWeaponShotDamage( + weapon: WeaponCombatInput, + attributes: StatValues, + bindings: AttributeBinding[], + headshot = false, + stageOrder?: CalculationStage[] +) { + const resolvedWeapon: Required = { + baseDamage: Number(weapon.baseDamage), + rpm: Number(weapon.rpm), + magazine: Number(weapon.magazine), + reloadSeconds: Number(weapon.reloadSeconds), + headshotMultiplier: Number(weapon.headshotMultiplier ?? 2) + }; + const values = hitValues(resolvedWeapon, attributes, bindings, stageOrder); + const critical = stageValues(attributes, bindings, 'critical_chance'); + const criticalChance = clamp(critical.additive / 100, 0, 1); + return headshot + ? values.headshot * (1 - criticalChance) + values.criticalHeadshot * criticalChance + : values.normal * (1 - criticalChance) + values.critical * criticalChance; +} + +export function resolveWeaponFiringCycle( + weapon: WeaponCombatInput, + attributes: StatValues, + bindings: AttributeBinding[] +): WeaponFiringCycle { + const rateOfFire = stageValues(attributes, bindings, 'rate_of_fire'); + const magazineSize = stageValues(attributes, bindings, 'magazine_size'); + const reloadSpeed = stageValues(attributes, bindings, 'reload_speed'); + return { + rpm: Number(weapon.rpm) + * (1 + rateOfFire.additive / 100) + * rateOfFire.multiplicative, + magazine: Math.max( + 1, + Number(weapon.magazine) + * (1 + magazineSize.additive / 100) + * magazineSize.multiplicative + ), + reloadSeconds: Number(weapon.reloadSeconds) / Math.max( + EPSILON, + (1 + reloadSpeed.additive / 100) * reloadSpeed.multiplicative + ) }; } diff --git a/src/lib/calculations/conditional.test.ts b/src/lib/calculations/conditional.test.ts new file mode 100644 index 0000000..da9251f --- /dev/null +++ b/src/lib/calculations/conditional.test.ts @@ -0,0 +1,401 @@ +import { describe, expect, it } from 'vitest'; +import { + TRIGGER_ACTION_REGISTRY, + evaluateConditionalProgram, + summarizeTrigger, + validateConditionalProgram, + validateTriggerConfiguration, + type ConditionalEvent, + type ConditionalProgram, + type TriggerConfiguration +} from './conditional.js'; + +const modifier = { + id: 'weapon-damage', + scope: 'equipped_weapon' as const, + application: 'active_modifier' as const, + attributeId: 'weapon-damage', + value: 3 +}; + +const program = ( + trigger: TriggerConfiguration, + overrides: Partial = {} +): ConditionalProgram => ({ + id: 'test-effect', + trigger, + lifecycle: { + cooldownSeconds: 0, + durationSeconds: 10, + maxStacks: 10, + stacksPerActivation: 1 + }, + outcomes: [modifier], + ...overrides +}); + +const shot = ( + at: number, + targetId: string | null, + hit = true, + headshot = false +): ConditionalEvent => ({ kind: 'shot', at, targetId, hit, headshot }); + +describe('trigger action registry', () => { + it('owns every action and validates discriminated parameters', () => { + expect(Object.keys(TRIGGER_ACTION_REGISTRY)).toHaveLength(15); + expect(validateTriggerConfiguration('same_target_hits_within_window', { + hitsRequired: 9, + windowSeconds: 3 + })).toMatchObject({ ok: true }); + expect(validateTriggerConfiguration('same_target_hits_within_window', { + hitsRequired: 0, + windowSeconds: -1 + })).toEqual({ + ok: false, + errors: ['Hits required must be a positive integer.', 'Window seconds must be greater than 0.'] + }); + expect(validateTriggerConfiguration('while_firing', { unexpected: true })).toMatchObject({ ok: false }); + expect(validateTriggerConfiguration('target_hit_within_distance', { meters: 0 })).toEqual({ + ok: false, + errors: ['Distance must be greater than 0.'] + }); + }); + + it('generates user-facing summaries from typed values', () => { + expect(summarizeTrigger({ + action: 'distinct_enemies_within_window', + params: { enemiesRequired: 4, windowSeconds: 2.5 } + })).toBe('Hit 4 distinct enemies within 2.5 seconds.'); + expect(summarizeTrigger({ + action: 'continuous_firing_duration', + params: { seconds: 1 } + })).toBe('Fire continuously for 1 second.'); + expect(summarizeTrigger({ + action: 'target_hit_within_distance', + params: { meters: 20 } + })).toBe('Hit a target within 20 meters.'); + }); + + it('validates unique health bands and their outcome relationships', () => { + const result = validateConditionalProgram(program({ + action: 'health_threshold_bands', + params: { + bands: [ + { thresholdPercent: 50, outcomeKey: 'low' }, + { thresholdPercent: 20, outcomeKey: 'critical' } + ] + } + }, { + outcomes: [ + { ...modifier, id: 'low', outcomeKey: 'low' }, + { ...modifier, id: 'critical', outcomeKey: 'critical', value: 10 } + ] + })); + expect(result).toMatchObject({ ok: true }); + }); + + it('rejects negative instant outcomes but permits negative Attribute modifiers', () => { + expect(validateConditionalProgram(program({ + action: 'headshot', + params: {} + }, { + outcomes: [{ + id: 'damage', + scope: 'target', + application: 'direct_damage', + basis: 'flat', + value: -10 + }] + }))).toEqual({ + ok: false, + errors: ['Instant outcome "damage" cannot use a negative value.'] + }); + expect(validateConditionalProgram(program({ + action: 'headshot', + params: {} + }, { + outcomes: [{ ...modifier, value: -10 }] + }))).toMatchObject({ ok: true }); + }); +}); + +describe('evaluateConditionalProgram', () => { + it('counts repeated hits against the same target inside the window', () => { + const result = evaluateConditionalProgram(program({ + action: 'same_target_hits_within_window', + params: { hitsRequired: 3, windowSeconds: 1 } + }), [ + shot(0, 'alpha'), + shot(0.2, 'bravo'), + shot(0.4, 'alpha'), + shot(0.8, 'alpha') + ], { mode: 'encounter', encounterSeconds: 5 }); + expect(result).toMatchObject({ + status: 'OK', + activationCount: 1, + maxStacksReached: 1 + }); + expect(result.activations[0]).toMatchObject({ at: 0.8, stacksBefore: 0, stacksAfter: 1 }); + }); + + it('counts each distinct enemy only once per window', () => { + const result = evaluateConditionalProgram(program({ + action: 'distinct_enemies_within_window', + params: { enemiesRequired: 3, windowSeconds: 2 } + }), [ + shot(0, 'alpha'), + shot(0.2, 'alpha'), + shot(0.4, 'bravo'), + shot(0.6, 'charlie') + ], { mode: 'encounter', encounterSeconds: 5 }); + expect(result.activationCount).toBe(1); + expect(result.activations[0].at).toBe(0.6); + }); + + it('resets consecutive hits after a miss or target change', () => { + const result = evaluateConditionalProgram(program({ + action: 'consecutive_same_target_hits', + params: { hitsRequired: 3 } + }), [ + shot(0, 'alpha'), + shot(0.1, 'alpha'), + shot(0.2, null, false), + shot(0.3, 'alpha'), + shot(0.4, 'bravo'), + shot(0.5, 'bravo'), + shot(0.6, 'bravo') + ], { mode: 'encounter', encounterSeconds: 5 }); + expect(result.activationCount).toBe(1); + expect(result.activations[0].at).toBe(0.6); + }); + + it('starts cooldown on activation and applies the activation after its triggering event', () => { + const result = evaluateConditionalProgram(program({ + action: 'headshot', + params: {} + }, { + lifecycle: { + cooldownSeconds: 3, + durationSeconds: 5, + maxStacks: 3, + stacksPerActivation: 1 + } + }), [ + shot(1, 'alpha', true, true), + shot(2, 'alpha', true, true), + shot(4, 'alpha', true, true) + ], { mode: 'encounter', encounterSeconds: 10 }); + expect(result.activations).toHaveLength(2); + expect(result.activations.map((activation) => activation.at)).toEqual([1, 4]); + expect(result.activations[0].effectiveAfter).toBeGreaterThan(1); + expect(result.activations[1]).toMatchObject({ + stacksBefore: 1, + stacksAfter: 2, + expiresAt: 9, + cooldownUntil: 7 + }); + }); + + it('refreshes one shared duration and clears every stack on expiry', () => { + const result = evaluateConditionalProgram(program({ + action: 'damage_taken', + params: {} + }, { + lifecycle: { + cooldownSeconds: 0, + durationSeconds: 3, + maxStacks: 3, + stacksPerActivation: 1 + } + }), [ + { kind: 'damage_taken', at: 0 }, + { kind: 'damage_taken', at: 1 }, + { kind: 'damage_taken', at: 5 } + ], { mode: 'encounter', encounterSeconds: 10 }); + expect(result.activations.map(({ stacksBefore, stacksAfter }) => ({ stacksBefore, stacksAfter }))).toEqual([ + { stacksBefore: 0, stacksAfter: 1 }, + { stacksBefore: 1, stacksAfter: 2 }, + { stacksBefore: 0, stacksAfter: 1 } + ]); + expect(result.intervals).toEqual([ + { start: 0.000001, end: 1.000001, stacks: 1, outcomeKey: null }, + { start: 1.000001, end: 4, stacks: 2, outcomeKey: null }, + { start: 5.000001, end: 8, stacks: 1, outcomeKey: null } + ]); + }); + + it('resets firing progress on reload without removing an active buff', () => { + const result = evaluateConditionalProgram(program({ + action: 'continuous_firing_duration', + params: { seconds: 2 } + }, { + lifecycle: { + cooldownSeconds: 0, + durationSeconds: 10, + maxStacks: 4, + stacksPerActivation: 1 + } + }), [ + { kind: 'firing_started', at: 0 }, + shot(0.5, 'alpha'), + { kind: 'reload_started', at: 3 }, + { kind: 'reload_completed', at: 4 }, + { kind: 'firing_started', at: 4 }, + { kind: 'firing_stopped', at: 7 } + ], { mode: 'encounter', encounterSeconds: 12 }); + expect(result.activations.map((activation) => activation.at)).toEqual([2, 6]); + expect(result.activations[1]).toMatchObject({ stacksBefore: 1, stacksAfter: 2, expiresAt: 16 }); + expect(result.uptimePercent).toBeCloseTo((10 / 12) * 100); + }); + + it('activates continuous firing only once in each uninterrupted segment', () => { + const result = evaluateConditionalProgram(program({ + action: 'continuous_firing_duration', + params: { seconds: 2 } + }), [ + { kind: 'firing_started', at: 0 }, + { kind: 'firing_stopped', at: 7 } + ], { mode: 'encounter', encounterSeconds: 10 }); + expect(result.activations.map((activation) => activation.at)).toEqual([2]); + }); + + it('uses only the strongest matching health band', () => { + const result = evaluateConditionalProgram(program({ + action: 'health_threshold_bands', + params: { + bands: [ + { thresholdPercent: 50, outcomeKey: 'low' }, + { thresholdPercent: 20, outcomeKey: 'critical' } + ] + } + }, { + lifecycle: { cooldownSeconds: 0, durationSeconds: 0, maxStacks: 1, stacksPerActivation: 1 }, + outcomes: [ + { ...modifier, id: 'low', outcomeKey: 'low', value: 5 }, + { ...modifier, id: 'critical', outcomeKey: 'critical', value: 10 } + ] + }), [], { + mode: 'encounter', + encounterSeconds: 20, + healthPercent: 15 + }); + expect(result.selectedOutcomeKey).toBe('critical'); + expect(result.intervals).toEqual([{ start: 0, end: 20, stacks: 1, outcomeKey: 'critical' }]); + }); + + it('allows a direct proc-rate override and reports per-stack instant outcomes', () => { + const result = evaluateConditionalProgram(program({ + action: 'weapon_kill', + params: {} + }, { + lifecycle: { cooldownSeconds: 0, durationSeconds: 20, maxStacks: 3, stacksPerActivation: 1 }, + outcomes: [{ + id: 'blast', + scope: 'target', + application: 'direct_damage', + basis: 'weapon_base_percent', + value: 25 + }] + }), [], { + mode: 'encounter', + encounterSeconds: 30, + directProcsPerMinute: 6 + }); + expect(result.activations.map((activation) => activation.at)).toEqual([10, 20, 30]); + expect(result.outcomeEvents.map((event) => event.value)).toEqual([25, 50, 75]); + }); + + it('uses real trigger events when the whole-program activation override is zero', () => { + const result = evaluateConditionalProgram(program({ + action: 'weapon_kill', + params: {} + }), [ + { kind: 'enemy_killed', at: 4 } + ], { + mode: 'encounter', + encounterSeconds: 30, + directProcsPerMinute: 0 + }); + expect(result.activations.map((activation) => activation.at)).toEqual([4]); + }); + + it('models headshot kills from enemy-killed timeline events', () => { + const result = evaluateConditionalProgram(program({ + action: 'headshot_kill', + params: {} + }), [ + { kind: 'enemy_killed', at: 2, headshot: false }, + { kind: 'enemy_killed', at: 7, headshot: true } + ], { mode: 'encounter', encounterSeconds: 15 }); + expect(result.activations.map((activation) => activation.at)).toEqual([7]); + }); + + it('activates on successful target hits while the target is within the configured distance', () => { + const inRange = evaluateConditionalProgram(program({ + action: 'target_hit_within_distance', + params: { meters: 20 } + }), [ + shot(1, 'alpha', false), + shot(2, null), + shot(3, 'alpha'), + shot(4, 'bravo') + ], { + mode: 'encounter', + encounterSeconds: 10, + distanceMeters: 20 + }); + expect(inRange.activations.map((activation) => activation.at)).toEqual([3, 4]); + + const outOfRange = evaluateConditionalProgram(program({ + action: 'target_hit_within_distance', + params: { meters: 20 } + }), [shot(3, 'alpha')], { + mode: 'encounter', + encounterSeconds: 10, + distanceMeters: 21 + }); + expect(outOfRange.activationCount).toBe(0); + }); + + it('requires a target-distance assumption for distance-limited hit events', () => { + const result = evaluateConditionalProgram(program({ + action: 'target_hit_within_distance', + params: { meters: 20 } + }), [shot(3, 'alpha')], { + mode: 'encounter', + encounterSeconds: 10 + }); + expect(result.activationCount).toBe(0); + }); + + it('keeps target proximity as a persistent condition rather than a hit event', () => { + const result = evaluateConditionalProgram(program({ + action: 'within_distance', + params: { meters: 20 } + }), [], { + mode: 'encounter', + encounterSeconds: 10, + distanceMeters: 20 + }); + expect(result.activationCount).toBe(1); + expect(result.intervals).toEqual([{ start: 0, end: 10, stacks: 10, outcomeKey: null }]); + }); + + it('keeps while-firing effects active only inside firing segments', () => { + const result = evaluateConditionalProgram(program({ + action: 'while_firing', + params: {} + }), [ + { kind: 'firing_started', at: 1 }, + { kind: 'firing_stopped', at: 3 }, + { kind: 'firing_started', at: 5 }, + { kind: 'reload_started', at: 8 } + ], { mode: 'encounter', encounterSeconds: 10 }); + expect(result.intervals).toEqual([ + { start: 1, end: 3, stacks: 10, outcomeKey: null }, + { start: 5, end: 8, stacks: 10, outcomeKey: null } + ]); + expect(result.uptimePercent).toBe(50); + }); +}); diff --git a/src/lib/calculations/conditional.ts b/src/lib/calculations/conditional.ts new file mode 100644 index 0000000..4eda9ab --- /dev/null +++ b/src/lib/calculations/conditional.ts @@ -0,0 +1,964 @@ +export const TRIGGER_ACTION_KEYS = [ + 'same_target_hits_within_window', + 'distinct_enemies_within_window', + 'consecutive_same_target_hits', + 'continuous_firing_duration', + 'while_firing', + 'health_threshold_bands', + 'headshot', + 'headshot_kill', + 'weapon_kill', + 'target_hit_within_distance', + 'damage_taken', + 'weapon_swap', + 'reload', + 'within_distance', + 'extra_health_active' +] as const; + +export type TriggerActionKey = (typeof TRIGGER_ACTION_KEYS)[number]; + +export type SameTargetHitsParams = { + hitsRequired: number; + windowSeconds: number; +}; +export type DistinctEnemiesParams = { + enemiesRequired: number; + windowSeconds: number; +}; +export type ConsecutiveHitsParams = { + hitsRequired: number; +}; +export type ContinuousFiringParams = { + seconds: number; +}; +export type HealthThresholdBand = { + thresholdPercent: number; + outcomeKey: string; +}; +export type HealthThresholdParams = { + bands: HealthThresholdBand[]; +}; +export type DistanceParams = { + meters: number; +}; +export type EmptyTriggerParams = Record; + +export type TriggerParamsByAction = { + same_target_hits_within_window: SameTargetHitsParams; + distinct_enemies_within_window: DistinctEnemiesParams; + consecutive_same_target_hits: ConsecutiveHitsParams; + continuous_firing_duration: ContinuousFiringParams; + while_firing: EmptyTriggerParams; + health_threshold_bands: HealthThresholdParams; + headshot: EmptyTriggerParams; + headshot_kill: EmptyTriggerParams; + weapon_kill: EmptyTriggerParams; + target_hit_within_distance: DistanceParams; + damage_taken: EmptyTriggerParams; + weapon_swap: EmptyTriggerParams; + reload: EmptyTriggerParams; + within_distance: DistanceParams; + extra_health_active: EmptyTriggerParams; +}; + +export type TriggerConfiguration = { + [K in TriggerActionKey]: { + action: K; + params: TriggerParamsByAction[K]; + } +}[TriggerActionKey]; + +export type EffectScope = + | 'self' + | 'equipped_weapon' + | 'target' + | 'allies' + | 'self_and_allies' + | 'assigned_ability' + | 'all_skills'; + +export type ModifierOutcome = { + id: string; + outcomeKey?: string; + scope: EffectScope; + application: 'active_modifier'; + attributeId: string; + value: number; +}; +export type DirectDamageOutcome = { + id: string; + outcomeKey?: string; + scope: EffectScope; + application: 'direct_damage'; + basis: 'flat' | 'weapon_base_percent'; + value: number; +}; +export type RestorationOutcome = { + id: string; + outcomeKey?: string; + scope: EffectScope; + application: 'restore_health' | 'restore_armor' | 'restore_ammo'; + value: number; +}; +export type ConditionalOutcome = ModifierOutcome | DirectDamageOutcome | RestorationOutcome; + +export type ConditionalLifecycle = { + cooldownSeconds: number; + durationSeconds: number; + maxStacks: number; + stacksPerActivation: number; +}; + +export type ConditionalProgram = { + id: string; + trigger: TriggerConfiguration; + lifecycle: ConditionalLifecycle; + outcomes: ConditionalOutcome[]; +}; + +export type ShotEvent = { + kind: 'shot'; + at: number; + targetId?: string | null; + hit: boolean; + headshot?: boolean; +}; +export type EnemyKilledEvent = { + kind: 'enemy_killed'; + at: number; + targetId?: string | null; + headshot?: boolean; +}; +export type ConditionalEvent = + | ShotEvent + | EnemyKilledEvent + | { kind: 'firing_started'; at: number } + | { kind: 'firing_stopped'; at: number } + | { kind: 'reload_started'; at: number } + | { kind: 'reload_completed'; at: number } + | { kind: 'damage_taken'; at: number } + | { kind: 'weapon_swapped'; at: number }; + +export type ConditionalMode = 'off' | 'peak' | 'encounter'; + +export type ConditionalAssumptions = { + mode: ConditionalMode; + encounterSeconds: number; + healthPercent?: number; + distanceMeters?: number; + extraHealthActive?: boolean; + directProcsPerMinute?: number; +}; + +export type ValidationResult = + | { ok: true; value: T } + | { ok: false; errors: string[] }; + +export type TriggerActionDefinition = { + key: TriggerActionKey; + label: string; + validate: (params: unknown) => ValidationResult; + summarize: (params: TriggerParamsByAction[TriggerActionKey]) => string; +}; + +export type ConditionalActivation = { + at: number; + effectiveAfter: number; + stacksBefore: number; + stacksAfter: number; + expiresAt: number; + cooldownUntil: number; + outcomeKey: string | null; +}; + +export type ConditionalStateInterval = { + start: number; + end: number; + stacks: number; + outcomeKey: string | null; +}; + +export type ConditionalOutcomeEvent = { + at: number; + effectiveAfter: number; + outcomeId: string; + application: Exclude; + scope: EffectScope; + value: number; + basis: DirectDamageOutcome['basis'] | null; + stacks: number; +}; + +export type ConditionalEvaluation = { + status: 'OK' | 'INVALID'; + errors: string[]; + programId: string; + mode: ConditionalMode; + activations: ConditionalActivation[]; + intervals: ConditionalStateInterval[]; + outcomeEvents: ConditionalOutcomeEvent[]; + activationCount: number; + maxStacksReached: number; + averageStacks: number; + uptimePercent: number; + selectedOutcomeKey: string | null; +}; + +const EPSILON = 0.000_001; +const finiteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); +const isObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); +const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value)); +const plural = (value: number, singular: string, pluralForm = `${singular}s`) => + `${formatNumber(value)} ${value === 1 ? singular : pluralForm}`; +const formatNumber = (value: number) => Number.isInteger(value) ? String(value) : String(Number(value.toFixed(2))); + +function positiveNumber(value: unknown, name: string, errors: string[]) { + if (!finiteNumber(value) || value <= 0) errors.push(`${name} must be greater than 0.`); +} + +function positiveInteger(value: unknown, name: string, errors: string[]) { + if (!Number.isInteger(value) || Number(value) <= 0) errors.push(`${name} must be a positive integer.`); +} + +function validateEmpty(params: unknown): ValidationResult { + if (params === undefined || (isObject(params) && Object.keys(params).length === 0)) { + return { ok: true, value: {} }; + } + return { ok: false, errors: ['This trigger does not accept parameters.'] }; +} + +function validateSameTarget(params: unknown): ValidationResult { + const errors: string[] = []; + if (!isObject(params)) return { ok: false, errors: ['Trigger parameters must be an object.'] }; + positiveInteger(params.hitsRequired, 'Hits required', errors); + positiveNumber(params.windowSeconds, 'Window seconds', errors); + return errors.length + ? { ok: false, errors } + : { ok: true, value: { hitsRequired: Number(params.hitsRequired), windowSeconds: Number(params.windowSeconds) } }; +} + +function validateDistinctEnemies(params: unknown): ValidationResult { + const errors: string[] = []; + if (!isObject(params)) return { ok: false, errors: ['Trigger parameters must be an object.'] }; + positiveInteger(params.enemiesRequired, 'Enemies required', errors); + positiveNumber(params.windowSeconds, 'Window seconds', errors); + return errors.length + ? { ok: false, errors } + : { ok: true, value: { enemiesRequired: Number(params.enemiesRequired), windowSeconds: Number(params.windowSeconds) } }; +} + +function validateConsecutive(params: unknown): ValidationResult { + const errors: string[] = []; + if (!isObject(params)) return { ok: false, errors: ['Trigger parameters must be an object.'] }; + positiveInteger(params.hitsRequired, 'Hits required', errors); + return errors.length + ? { ok: false, errors } + : { ok: true, value: { hitsRequired: Number(params.hitsRequired) } }; +} + +function validateContinuousFiring(params: unknown): ValidationResult { + const errors: string[] = []; + if (!isObject(params)) return { ok: false, errors: ['Trigger parameters must be an object.'] }; + positiveNumber(params.seconds, 'Firing seconds', errors); + return errors.length + ? { ok: false, errors } + : { ok: true, value: { seconds: Number(params.seconds) } }; +} + +function validateHealthBands(params: unknown): ValidationResult { + const errors: string[] = []; + if (!isObject(params) || !Array.isArray(params.bands)) { + return { ok: false, errors: ['Health bands must be an array.'] }; + } + if (params.bands.length < 1 || params.bands.length > 5) { + errors.push('Health threshold triggers require between 1 and 5 bands.'); + } + const bands: HealthThresholdBand[] = []; + const thresholds = new Set(); + const outcomeKeys = new Set(); + for (const [index, candidate] of params.bands.entries()) { + if (!isObject(candidate)) { + errors.push(`Health band ${index + 1} must be an object.`); + continue; + } + const threshold = candidate.thresholdPercent; + const outcomeKey = typeof candidate.outcomeKey === 'string' ? candidate.outcomeKey.trim() : ''; + if (!finiteNumber(threshold) || threshold < 0 || threshold > 100) { + errors.push(`Health band ${index + 1} threshold must be between 0 and 100.`); + } + if (!outcomeKey) errors.push(`Health band ${index + 1} requires an outcome key.`); + if (finiteNumber(threshold) && thresholds.has(threshold)) { + errors.push(`Health threshold ${formatNumber(threshold)}% is duplicated.`); + } + if (outcomeKey && outcomeKeys.has(outcomeKey)) errors.push(`Outcome key "${outcomeKey}" is duplicated.`); + if (finiteNumber(threshold)) thresholds.add(threshold); + if (outcomeKey) outcomeKeys.add(outcomeKey); + if (finiteNumber(threshold) && outcomeKey) bands.push({ thresholdPercent: threshold, outcomeKey }); + } + return errors.length + ? { ok: false, errors } + : { ok: true, value: { bands: bands.sort((a, b) => a.thresholdPercent - b.thresholdPercent) } }; +} + +function validateDistance(params: unknown): ValidationResult { + const errors: string[] = []; + if (!isObject(params)) return { ok: false, errors: ['Trigger parameters must be an object.'] }; + positiveNumber(params.meters, 'Distance', errors); + return errors.length + ? { ok: false, errors } + : { ok: true, value: { meters: Number(params.meters) } }; +} + +const emptySummary = () => ''; + +export const TRIGGER_ACTION_REGISTRY: Readonly> = { + same_target_hits_within_window: { + key: 'same_target_hits_within_window', + label: 'Same-target hits within a window', + validate: validateSameTarget, + summarize: (params) => { + const value = params as SameTargetHitsParams; + return `Hit the same enemy ${plural(value.hitsRequired, 'time')} within ${plural(value.windowSeconds, 'second')}.`; + } + }, + distinct_enemies_within_window: { + key: 'distinct_enemies_within_window', + label: 'Distinct enemies hit within a window', + validate: validateDistinctEnemies, + summarize: (params) => { + const value = params as DistinctEnemiesParams; + return `Hit ${plural(value.enemiesRequired, 'distinct enemy', 'distinct enemies')} within ${plural(value.windowSeconds, 'second')}.`; + } + }, + consecutive_same_target_hits: { + key: 'consecutive_same_target_hits', + label: 'Consecutive same-target hits', + validate: validateConsecutive, + summarize: (params) => { + const value = params as ConsecutiveHitsParams; + return `Hit the same enemy ${plural(value.hitsRequired, 'consecutive time')}.`; + } + }, + continuous_firing_duration: { + key: 'continuous_firing_duration', + label: 'Continuous firing duration', + validate: validateContinuousFiring, + summarize: (params) => { + const value = params as ContinuousFiringParams; + return `Fire continuously for ${plural(value.seconds, 'second')}.`; + } + }, + while_firing: { + key: 'while_firing', + label: 'While firing', + validate: validateEmpty, + summarize: () => 'While firing.' + }, + health_threshold_bands: { + key: 'health_threshold_bands', + label: 'Health threshold bands', + validate: validateHealthBands, + summarize: (params) => { + const value = params as HealthThresholdParams; + const thresholds = [...value.bands] + .sort((a, b) => b.thresholdPercent - a.thresholdPercent) + .map((band) => `${formatNumber(band.thresholdPercent)}% (${band.outcomeKey})`); + return `Use the strongest matching effect while health is at or below ${thresholds.join(', ')}.`; + } + }, + headshot: { + key: 'headshot', + label: 'Headshot', + validate: validateEmpty, + summarize: () => 'Land a headshot.' + }, + headshot_kill: { + key: 'headshot_kill', + label: 'Headshot kill', + validate: validateEmpty, + summarize: () => 'Kill an enemy with a headshot.' + }, + weapon_kill: { + key: 'weapon_kill', + label: 'Weapon kill', + validate: validateEmpty, + summarize: () => 'Kill an enemy with a weapon.' + }, + target_hit_within_distance: { + key: 'target_hit_within_distance', + label: 'Target hit within distance', + validate: validateDistance, + summarize: (params) => { + const value = params as DistanceParams; + return `Hit a target within ${plural(value.meters, 'meter')}.`; + } + }, + damage_taken: { + key: 'damage_taken', + label: 'Damage taken', + validate: validateEmpty, + summarize: () => 'Take damage.' + }, + weapon_swap: { + key: 'weapon_swap', + label: 'Weapon swap', + validate: validateEmpty, + summarize: () => 'Swap weapons.' + }, + reload: { + key: 'reload', + label: 'Reload', + validate: validateEmpty, + summarize: () => 'Start reloading.' + }, + within_distance: { + key: 'within_distance', + label: 'Target within distance', + validate: validateDistance, + summarize: (params) => { + const value = params as DistanceParams; + return `While the target is within ${plural(value.meters, 'meter')}.`; + } + }, + extra_health_active: { + key: 'extra_health_active', + label: 'Extra health active', + validate: validateEmpty, + summarize: () => 'While extra health is active.' + } +}; + +export function isTriggerActionKey(value: unknown): value is TriggerActionKey { + return typeof value === 'string' && (TRIGGER_ACTION_KEYS as readonly string[]).includes(value); +} + +export function validateTriggerConfiguration( + action: TriggerActionKey, + params: unknown +): ValidationResult { + const result = TRIGGER_ACTION_REGISTRY[action].validate(params); + if (!result.ok) return result; + return { ok: true, value: { action, params: result.value } as TriggerConfiguration }; +} + +export function summarizeTrigger(configuration: TriggerConfiguration): string { + return TRIGGER_ACTION_REGISTRY[configuration.action].summarize(configuration.params); +} + +export function validateConditionalProgram(program: ConditionalProgram): ValidationResult { + const errors: string[] = []; + if (!program.id.trim()) errors.push('Program ID is required.'); + const trigger = validateTriggerConfiguration(program.trigger.action, program.trigger.params); + if (!trigger.ok) errors.push(...trigger.errors.map((error) => `Trigger: ${error}`)); + const lifecycle = program.lifecycle; + if (!finiteNumber(lifecycle.cooldownSeconds) || lifecycle.cooldownSeconds < 0) { + errors.push('Cooldown seconds must be 0 or greater.'); + } + if (!finiteNumber(lifecycle.durationSeconds) || lifecycle.durationSeconds < 0) { + errors.push('Duration seconds must be 0 or greater.'); + } + positiveInteger(lifecycle.maxStacks, 'Maximum stacks', errors); + positiveInteger(lifecycle.stacksPerActivation, 'Stacks per activation', errors); + if ( + Number.isInteger(lifecycle.maxStacks) + && Number.isInteger(lifecycle.stacksPerActivation) + && lifecycle.stacksPerActivation > lifecycle.maxStacks + ) { + errors.push('Stacks per activation cannot exceed maximum stacks.'); + } + const outcomeIds = new Set(); + const healthOutcomeKeys = program.trigger.action === 'health_threshold_bands' + ? new Set((program.trigger.params as HealthThresholdParams).bands.map((band) => band.outcomeKey)) + : null; + for (const [index, outcome] of program.outcomes.entries()) { + if (!outcome.id.trim()) errors.push(`Outcome ${index + 1} requires an ID.`); + if (outcomeIds.has(outcome.id)) errors.push(`Outcome ID "${outcome.id}" is duplicated.`); + outcomeIds.add(outcome.id); + if (!finiteNumber(outcome.value)) errors.push(`Outcome "${outcome.id}" requires a finite value.`); + if ( + outcome.application !== 'active_modifier' + && finiteNumber(outcome.value) + && outcome.value < 0 + ) { + errors.push(`Instant outcome "${outcome.id}" cannot use a negative value.`); + } + if (outcome.application === 'active_modifier' && !outcome.attributeId.trim()) { + errors.push(`Modifier outcome "${outcome.id}" requires an Attribute ID.`); + } + if (healthOutcomeKeys && (!outcome.outcomeKey || !healthOutcomeKeys.has(outcome.outcomeKey))) { + errors.push(`Outcome "${outcome.id}" must reference a configured health-band outcome key.`); + } + } + if (healthOutcomeKeys) { + const provided = new Set(program.outcomes.map((outcome) => outcome.outcomeKey).filter(Boolean)); + for (const key of healthOutcomeKeys) { + if (!provided.has(key)) errors.push(`Health-band outcome "${key}" has no configured effect.`); + } + } + return errors.length ? { ok: false, errors } : { ok: true, value: program }; +} + +type CandidateActivation = { at: number; outcomeKey: string | null }; +type FiringSegment = { start: number; end: number }; + +function orderedEvents(events: ConditionalEvent[], encounterSeconds: number) { + return events + .map((event, index) => ({ event, index })) + .filter(({ event }) => finiteNumber(event.at) && event.at >= 0 && event.at <= encounterSeconds) + .sort((left, right) => left.event.at - right.event.at || left.index - right.index) + .map(({ event }) => event); +} + +function firingSegments(events: ConditionalEvent[], encounterSeconds: number): FiringSegment[] { + const segments: FiringSegment[] = []; + let startedAt: number | null = null; + for (const event of events) { + if (event.kind === 'firing_started') { + if (startedAt === null) startedAt = event.at; + continue; + } + if (event.kind === 'shot' && startedAt === null) { + startedAt = event.at; + continue; + } + if ((event.kind === 'firing_stopped' || event.kind === 'reload_started') && startedAt !== null) { + segments.push({ start: startedAt, end: event.at }); + startedAt = null; + } + } + if (startedAt !== null) segments.push({ start: startedAt, end: encounterSeconds }); + return segments.filter((segment) => segment.end - segment.start > EPSILON); +} + +function eventCandidates( + trigger: TriggerConfiguration, + events: ConditionalEvent[], + assumptions: ConditionalAssumptions +): CandidateActivation[] { + switch (trigger.action) { + case 'same_target_hits_within_window': { + const params = trigger.params; + const hits = new Map(); + const candidates: CandidateActivation[] = []; + for (const event of events) { + if (event.kind !== 'shot' || !event.hit || !event.targetId) continue; + const recent = (hits.get(event.targetId) ?? []).filter( + (at) => event.at - at <= params.windowSeconds + EPSILON + ); + recent.push(event.at); + if (recent.length >= params.hitsRequired) { + candidates.push({ at: event.at, outcomeKey: null }); + hits.set(event.targetId, []); + } else { + hits.set(event.targetId, recent); + } + } + return candidates; + } + case 'distinct_enemies_within_window': { + const params = trigger.params; + let recent = new Map(); + const candidates: CandidateActivation[] = []; + for (const event of events) { + if (event.kind !== 'shot' || !event.hit || !event.targetId) continue; + recent = new Map( + [...recent].filter(([, at]) => event.at - at <= params.windowSeconds + EPSILON) + ); + recent.set(event.targetId, event.at); + if (recent.size >= params.enemiesRequired) { + candidates.push({ at: event.at, outcomeKey: null }); + recent.clear(); + } + } + return candidates; + } + case 'consecutive_same_target_hits': { + const params = trigger.params; + let targetId: string | null = null; + let count = 0; + const candidates: CandidateActivation[] = []; + for (const event of events) { + if (event.kind !== 'shot') continue; + if (!event.hit || !event.targetId) { + targetId = null; + count = 0; + continue; + } + if (event.targetId !== targetId) { + targetId = event.targetId; + count = 1; + } else { + count += 1; + } + if (count >= params.hitsRequired) { + candidates.push({ at: event.at, outcomeKey: null }); + count = 0; + } + } + return candidates; + } + case 'continuous_firing_duration': { + return firingSegments(events, assumptions.encounterSeconds) + .filter((segment) => segment.end - segment.start + EPSILON >= trigger.params.seconds) + .map((segment) => ({ + at: segment.start + trigger.params.seconds, + outcomeKey: null + })); + } + case 'headshot': + return events + .filter((event): event is ShotEvent => event.kind === 'shot' && event.hit && event.headshot === true) + .map((event) => ({ at: event.at, outcomeKey: null })); + case 'headshot_kill': + return events + .filter((event): event is EnemyKilledEvent => event.kind === 'enemy_killed' && event.headshot === true) + .map((event) => ({ at: event.at, outcomeKey: null })); + case 'weapon_kill': + return events + .filter((event) => event.kind === 'enemy_killed') + .map((event) => ({ at: event.at, outcomeKey: null })); + case 'target_hit_within_distance': + if ( + !finiteNumber(assumptions.distanceMeters) + || assumptions.distanceMeters > trigger.params.meters + EPSILON + ) return []; + return events + .filter((event): event is ShotEvent => + event.kind === 'shot' && event.hit && Boolean(event.targetId) + ) + .map((event) => ({ at: event.at, outcomeKey: null })); + case 'damage_taken': + return events + .filter((event) => event.kind === 'damage_taken') + .map((event) => ({ at: event.at, outcomeKey: null })); + case 'weapon_swap': + return events + .filter((event) => event.kind === 'weapon_swapped') + .map((event) => ({ at: event.at, outcomeKey: null })); + case 'reload': + return events + .filter((event) => event.kind === 'reload_started') + .map((event) => ({ at: event.at, outcomeKey: null })); + case 'while_firing': + case 'health_threshold_bands': + case 'within_distance': + case 'extra_health_active': + return []; + } +} + +function directRateCandidates(procsPerMinute: number, encounterSeconds: number): CandidateActivation[] { + if (!finiteNumber(procsPerMinute) || procsPerMinute <= 0) return []; + const interval = 60 / procsPerMinute; + const candidates: CandidateActivation[] = []; + for (let at = interval; at <= encounterSeconds + EPSILON; at += interval) { + candidates.push({ at, outcomeKey: null }); + } + return candidates; +} + +function strongestHealthOutcome(params: HealthThresholdParams, healthPercent: number | undefined) { + if (!finiteNumber(healthPercent)) return null; + const current = clamp(healthPercent, 0, 100); + return [...params.bands] + .sort((left, right) => left.thresholdPercent - right.thresholdPercent) + .find((band) => current <= band.thresholdPercent + EPSILON)?.outcomeKey ?? null; +} + +function persistentCondition( + trigger: TriggerConfiguration, + assumptions: ConditionalAssumptions +): { active: boolean; outcomeKey: string | null } { + switch (trigger.action) { + case 'health_threshold_bands': { + const outcomeKey = strongestHealthOutcome(trigger.params, assumptions.healthPercent); + return { active: outcomeKey !== null, outcomeKey }; + } + case 'within_distance': + return { + active: finiteNumber(assumptions.distanceMeters) + && assumptions.distanceMeters <= trigger.params.meters + EPSILON, + outcomeKey: null + }; + case 'extra_health_active': + return { active: assumptions.extraHealthActive === true, outcomeKey: null }; + default: + return { active: false, outcomeKey: null }; + } +} + +function outcomeEventsAt( + program: ConditionalProgram, + at: number, + stacks: number, + outcomeKey: string | null +): ConditionalOutcomeEvent[] { + return program.outcomes + .filter((outcome) => outcome.application !== 'active_modifier') + .filter((outcome) => outcomeKey === null || outcome.outcomeKey === outcomeKey) + .map((outcome) => ({ + at, + effectiveAfter: at + EPSILON, + outcomeId: outcome.id, + application: outcome.application, + scope: outcome.scope, + value: outcome.value * stacks, + basis: outcome.application === 'direct_damage' ? outcome.basis : null, + stacks + })); +} + +function summarizeIntervals(intervals: ConditionalStateInterval[], encounterSeconds: number) { + if (encounterSeconds <= 0) return { averageStacks: 0, uptimePercent: 0 }; + let stackSeconds = 0; + let activeSeconds = 0; + for (const interval of intervals) { + const duration = Math.max(0, interval.end - interval.start); + stackSeconds += duration * interval.stacks; + if (interval.stacks > 0) activeSeconds += duration; + } + return { + averageStacks: stackSeconds / encounterSeconds, + uptimePercent: clamp(activeSeconds / encounterSeconds * 100, 0, 100) + }; +} + +function stateIntervals( + activations: ConditionalActivation[], + encounterSeconds: number +): ConditionalStateInterval[] { + const boundaries = new Set([0, encounterSeconds]); + for (const activation of activations) { + boundaries.add(clamp(activation.effectiveAfter, 0, encounterSeconds)); + boundaries.add(clamp(activation.expiresAt, 0, encounterSeconds)); + } + const ordered = [...boundaries].sort((left, right) => left - right); + const intervals: ConditionalStateInterval[] = []; + for (let index = 0; index < ordered.length - 1; index += 1) { + const start = ordered[index]; + const end = ordered[index + 1]; + if (end - start <= EPSILON) continue; + const active = [...activations] + .reverse() + .find((activation) => + activation.effectiveAfter <= start + EPSILON + && activation.expiresAt > start + EPSILON + ); + if (!active || active.stacksAfter <= 0) continue; + const prior = intervals.at(-1); + if ( + prior + && Math.abs(prior.end - start) <= EPSILON + && prior.stacks === active.stacksAfter + && prior.outcomeKey === active.outcomeKey + ) { + prior.end = end; + } else { + intervals.push({ start, end, stacks: active.stacksAfter, outcomeKey: active.outcomeKey }); + } + } + return intervals; +} + +function evaluateCandidates( + program: ConditionalProgram, + candidates: CandidateActivation[], + encounterSeconds: number +) { + const activations: ConditionalActivation[] = []; + const outcomeEvents: ConditionalOutcomeEvent[] = []; + let stacks = 0; + let expiresAt = 0; + let cooldownUntil = 0; + for (const candidate of candidates.sort((left, right) => left.at - right.at)) { + if (candidate.at < 0 || candidate.at > encounterSeconds + EPSILON) continue; + if (candidate.at + EPSILON < cooldownUntil) continue; + if (candidate.at + EPSILON >= expiresAt) stacks = 0; + const stacksBefore = stacks; + stacks = Math.min( + program.lifecycle.maxStacks, + stacks + program.lifecycle.stacksPerActivation + ); + expiresAt = candidate.at + program.lifecycle.durationSeconds; + cooldownUntil = candidate.at + program.lifecycle.cooldownSeconds; + const activation: ConditionalActivation = { + at: candidate.at, + effectiveAfter: candidate.at + EPSILON, + stacksBefore, + stacksAfter: stacks, + expiresAt, + cooldownUntil, + outcomeKey: candidate.outcomeKey + }; + activations.push(activation); + outcomeEvents.push(...outcomeEventsAt(program, candidate.at, stacks, candidate.outcomeKey)); + } + return { activations, outcomeEvents }; +} + +function evaluatePersistent( + program: ConditionalProgram, + assumptions: ConditionalAssumptions, + outcomeKey: string | null +) { + const stacks = program.lifecycle.maxStacks; + const activation: ConditionalActivation = { + at: 0, + effectiveAfter: 0, + stacksBefore: 0, + stacksAfter: stacks, + expiresAt: assumptions.encounterSeconds, + cooldownUntil: 0, + outcomeKey + }; + const intervals = assumptions.encounterSeconds > 0 + ? [{ start: 0, end: assumptions.encounterSeconds, stacks, outcomeKey }] + : []; + return { + activations: [activation], + intervals, + outcomeEvents: outcomeEventsAt(program, 0, stacks, outcomeKey) + }; +} + +function evaluateWhileFiring( + program: ConditionalProgram, + events: ConditionalEvent[], + assumptions: ConditionalAssumptions +) { + const segments = firingSegments(events, assumptions.encounterSeconds); + const stacks = program.lifecycle.maxStacks; + const activations = segments.map((segment) => ({ + at: segment.start, + effectiveAfter: segment.start + EPSILON, + stacksBefore: 0, + stacksAfter: stacks, + expiresAt: segment.end, + cooldownUntil: segment.start, + outcomeKey: null + })); + return { + activations, + intervals: segments.map((segment) => ({ + start: segment.start, + end: segment.end, + stacks, + outcomeKey: null + })), + outcomeEvents: activations.flatMap((activation) => + outcomeEventsAt(program, activation.at, stacks, null) + ) + }; +} + +function invalidEvaluation(programId: string, mode: ConditionalMode, errors: string[]): ConditionalEvaluation { + return { + status: 'INVALID', + errors, + programId, + mode, + activations: [], + intervals: [], + outcomeEvents: [], + activationCount: 0, + maxStacksReached: 0, + averageStacks: 0, + uptimePercent: 0, + selectedOutcomeKey: null + }; +} + +export function evaluateConditionalProgram( + program: ConditionalProgram, + events: ConditionalEvent[], + assumptions: ConditionalAssumptions +): ConditionalEvaluation { + const validation = validateConditionalProgram(program); + const assumptionErrors: string[] = []; + if (!finiteNumber(assumptions.encounterSeconds) || assumptions.encounterSeconds <= 0) { + assumptionErrors.push('Encounter seconds must be greater than 0.'); + } + if ( + assumptions.directProcsPerMinute !== undefined + && (!finiteNumber(assumptions.directProcsPerMinute) || assumptions.directProcsPerMinute < 0) + ) { + assumptionErrors.push('Direct procs per minute must be 0 or greater.'); + } + if (!validation.ok || assumptionErrors.length) { + return invalidEvaluation( + program.id, + assumptions.mode, + [...(validation.ok ? [] : validation.errors), ...assumptionErrors] + ); + } + if (assumptions.mode === 'off') { + return { + ...invalidEvaluation(program.id, assumptions.mode, []), + status: 'OK' + }; + } + const ordered = orderedEvents(events, assumptions.encounterSeconds); + let activations: ConditionalActivation[] = []; + let intervals: ConditionalStateInterval[] = []; + let outcomeEvents: ConditionalOutcomeEvent[] = []; + let selectedOutcomeKey: string | null = null; + + if (assumptions.mode === 'peak') { + selectedOutcomeKey = program.trigger.action === 'health_threshold_bands' + ? strongestHealthOutcome(program.trigger.params, assumptions.healthPercent) + ?? [...program.trigger.params.bands] + .sort((left, right) => left.thresholdPercent - right.thresholdPercent)[0]?.outcomeKey + ?? null + : null; + const persistent = evaluatePersistent(program, assumptions, selectedOutcomeKey); + ({ activations, intervals, outcomeEvents } = persistent); + } else if ( + finiteNumber(assumptions.directProcsPerMinute) + && assumptions.directProcsPerMinute > 0 + ) { + const evaluated = evaluateCandidates( + program, + directRateCandidates(assumptions.directProcsPerMinute, assumptions.encounterSeconds), + assumptions.encounterSeconds + ); + activations = evaluated.activations; + outcomeEvents = evaluated.outcomeEvents; + intervals = stateIntervals(activations, assumptions.encounterSeconds); + } else if (program.trigger.action === 'while_firing') { + const evaluated = evaluateWhileFiring(program, ordered, assumptions); + ({ activations, intervals, outcomeEvents } = evaluated); + } else { + const condition = persistentCondition(program.trigger, assumptions); + if (condition.active) { + selectedOutcomeKey = condition.outcomeKey; + const persistent = evaluatePersistent(program, assumptions, condition.outcomeKey); + ({ activations, intervals, outcomeEvents } = persistent); + } else { + const evaluated = evaluateCandidates( + program, + eventCandidates(program.trigger, ordered, assumptions), + assumptions.encounterSeconds + ); + activations = evaluated.activations; + outcomeEvents = evaluated.outcomeEvents; + intervals = stateIntervals(activations, assumptions.encounterSeconds); + } + } + const summary = summarizeIntervals(intervals, assumptions.encounterSeconds); + return { + status: 'OK', + errors: [], + programId: program.id, + mode: assumptions.mode, + activations, + intervals, + outcomeEvents, + activationCount: activations.length, + maxStacksReached: Math.max(0, ...activations.map((activation) => activation.stacksAfter)), + averageStacks: summary.averageStacks, + uptimePercent: summary.uptimePercent, + selectedOutcomeKey + }; +} diff --git a/src/lib/calculations/mechanic-editor.test.ts b/src/lib/calculations/mechanic-editor.test.ts new file mode 100644 index 0000000..f89dcc1 --- /dev/null +++ b/src/lib/calculations/mechanic-editor.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest'; +import { + parseConditionalMechanicDraft, + type ConditionalMechanicDraftV1 +} from './mechanic-editor.js'; + +const draft = (): ConditionalMechanicDraftV1 => ({ + version: 1, + simulationReadiness: 'ready', + scenarioInputs: [], + pools: [{ + key: 'effect', + label: 'Effect', + stateKind: 'stack', + scope: 'agent', + initial: 0, + min: 0, + max: 10, + bestCaseValue: 10, + expiresAfterSeconds: 10 + }], + clauses: [{ + key: 'gain-effect', + label: 'Gain Effect', + event: 'weapon_hit', + recipeKey: 'same_target_hits_within_window', + if: { kind: 'exists', value: { source: 'event', path: 'targetId' } }, + reducer: { + kind: 'sliding_count', + threshold: 9, + windowSeconds: 3, + groupBy: { source: 'event', path: 'targetId' } + }, + cooldownSeconds: 2, + weight: 1, + mutations: [{ kind: 'add', pool: 'effect', value: 1 }], + outcomes: [{ + key: 'weapon-damage', + label: 'Weapon Damage', + phase: 'while_active', + kind: 'attribute_modifier', + scope: 'equipped_weapon', + basis: 'flat', + attributeId: 'weapon-damage', + statePool: 'effect', + perStack: true, + descriptionSlot: 1, + variants: { + I: { value: 3 }, + II: { value: 4.2 } + } + }] + }] +}); + +describe('conditional mechanic authoring payload', () => { + it('builds one validated runtime program per exact source variant', () => { + const result = parseConditionalMechanicDraft(draft(), { + variantKeys: ['I', 'II'], + programId: 'superior-firepower', + programName: 'Superior Firepower', + allowedAttributeIds: new Set(['weapon-damage']), + allowedRecipeKeys: new Set(['same_target_hits_within_window']) + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(Object.keys(result.value.programs)).toEqual(['I', 'II']); + expect(result.value.programs.I.clauses[0]).toMatchObject({ + id: 'gain-effect', + when: 'weapon_hit', + recipeKey: 'same_target_hits_within_window', + cooldownSeconds: 2, + weight: 1, + outcomes: [{ + id: 'weapon-damage', + scope: 'equipped_weapon', + value: 3 + }] + }); + expect(result.value.programs.II.clauses[0].outcomes?.[0].value).toBe(4.2); + }); + + it('preserves Happy Survival expressions, explicit cadence readiness, resources, and combat timing', () => { + const happy: ConditionalMechanicDraftV1 = { + version: 1, + simulationReadiness: 'requires_assumptions', + scenarioInputs: [{ + key: 'restoration-events-per-minute', + label: 'Restoration events per minute', + kind: 'number', + readiness: 'requires_assumption', + min: 0 + }], + pools: [{ + key: 'nearby-enemies', + label: 'Nearby Enemies', + stateKind: 'count', + scope: 'agent', + initial: 0, + min: 0, + max: 20, + bestCaseValue: 5 + }], + clauses: [{ + key: 'shot-health-exchange', + event: 'shot_fired', + reducer: { kind: 'none' }, + mutations: [{ kind: 'none' }], + outcomes: [ + { + key: 'health-cost', + label: 'Health Cost', + phase: 'on_event', + kind: 'resource_cost', + resource: 'health', + scope: 'self', + basis: 'max_health_percent', + variants: { base: { value: 2 } } + }, + { + key: 'bonus-damage', + label: 'Current Health Bonus Damage', + phase: 'on_event', + kind: 'direct_damage', + scope: 'target', + basis: 'current_health_percent', + combatTiming: 'current', + variants: { + base: { + value: 0.6, + expression: { + op: 'multiply', + left: 0.6, + right: { + op: 'add', + left: 1, + right: { + op: 'divide', + left: { + ref: { + source: 'equipment', + path: 'protocolTalentUpgrades' + } + }, + right: 5 + } + } + } + } + } + } + ] + }] + }; + const result = parseConditionalMechanicDraft(happy, { + variantKeys: ['base'], + programName: 'Happy Survival' + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.programs.base.clauses[0].outcomes).toEqual([ + expect.objectContaining({ kind: 'resource_cost', resource: 'health', value: 2 }), + expect.objectContaining({ + kind: 'direct_damage', + combatTiming: 'current', + value: expect.objectContaining({ + right: expect.objectContaining({ + right: expect.objectContaining({ + left: { + ref: { + source: 'equipment', + path: 'protocolTalentUpgrades' + } + } + }) + }) + }) + }) + ]); + }); + + it('requires the exact configured variants and returns validation errors without throwing', () => { + const invalid = draft(); + invalid.clauses[0].outcomes[0].variants = { + I: { value: 3 }, + legacy: { value: 99 } + }; + const result = parseConditionalMechanicDraft(invalid, { variantKeys: ['I', 'II'] }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain( + 'Clause 1 outcome 1 must define exactly these variants: I, II.' + ); + } + expect(() => parseConditionalMechanicDraft('{not-json', { variantKeys: ['base'] })) + .not.toThrow(); + expect(parseConditionalMechanicDraft('{not-json', { variantKeys: ['base'] })) + .toEqual({ + ok: false, + errors: ['The conditional mechanic contains malformed JSON.'] + }); + }); +}); diff --git a/src/lib/calculations/mechanic-editor.ts b/src/lib/calculations/mechanic-editor.ts new file mode 100644 index 0000000..990e1a8 --- /dev/null +++ b/src/lib/calculations/mechanic-editor.ts @@ -0,0 +1,605 @@ +import { + MECHANIC_EVENT_KINDS, + validateMechanicProgram, + type ClauseMutation, + type MechanicClause, + type MechanicEventKind, + type MechanicOutcome, + type MechanicOutcomeScope, + type MechanicProgram, + type MechanicResource, + type NumericExpression, + type OutcomeBasis, + type OutcomePhase, + type PoolDefinition, + type Predicate, + type Reducer, + type ScenarioInputDefinition, + type StateScope, + type ValueReference +} from './mechanic.js'; + +export const CONDITIONAL_MECHANIC_DRAFT_VERSION = 1 as const; + +export type MechanicStateKind = + | 'flag' + | 'count' + | 'stack' + | 'duration' + | 'scalar' + | 'target_mark' + | 'resource'; + +export type ConditionalMechanicPoolDraft = PoolDefinition & { + stateKind: MechanicStateKind; +}; + +export type ConditionalMechanicVariantValue = { + value: number; + expression?: NumericExpression; +}; + +export type ConditionalMechanicOutcomeDraft = Omit & { + key: string; + descriptionSlot?: number; + variants: Record; +}; + +export type ConditionalMechanicClauseDraft = Omit & { + key: string; + event: MechanicEventKind; + recipeKey?: string; + outcomes: ConditionalMechanicOutcomeDraft[]; +}; + +export type ConditionalMechanicDraftV1 = { + version: typeof CONDITIONAL_MECHANIC_DRAFT_VERSION; + simulationReadiness: 'ready' | 'requires_assumptions' | 'captured_only'; + scenarioInputs: ScenarioInputDefinition[]; + pools: ConditionalMechanicPoolDraft[]; + clauses: ConditionalMechanicClauseDraft[]; +}; + +export type ParseConditionalMechanicDraftOptions = { + variantKeys: readonly string[]; + programId?: string; + programName?: string; + allowedAttributeIds?: ReadonlySet; + allowedRecipeKeys?: ReadonlySet; +}; + +export type ParsedConditionalMechanicDraft = { + draft: ConditionalMechanicDraftV1; + programs: Record; +}; + +export type ConditionalMechanicDraftResult = + | { ok: true; value: ParsedConditionalMechanicDraft } + | { ok: false; errors: string[] }; + +const object = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); +const finite = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); +const scalar = (value: unknown) => + value === null || ['string', 'number', 'boolean'].includes(typeof value); +const identifier = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/; +const events = new Set(MECHANIC_EVENT_KINDS); +const phases = new Set(['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active']); +const outcomeKinds = new Set([ + 'attribute_modifier', + 'direct_damage', + 'restoration', + 'resource_cost', + 'marker' +]); +const scopes = new Set([ + 'self', + 'equipped_weapon', + 'target', + 'allies', + 'self_and_allies', + 'assigned_ability', + 'all_skills' +]); +const bases = new Set([ + 'flat', + 'max_health_percent', + 'current_health_percent', + 'weapon_base_percent' +]); +const resources = new Set(['health', 'armor', 'ammo']); +const stateKinds = new Set([ + 'flag', + 'count', + 'stack', + 'duration', + 'scalar', + 'target_mark', + 'resource' +]); + +function parseRaw(raw: unknown, errors: string[]) { + if (typeof raw !== 'string') return raw; + try { + return JSON.parse(raw); + } catch { + errors.push('The conditional mechanic contains malformed JSON.'); + return null; + } +} + +function key(value: unknown, path: string, errors: string[]) { + if (typeof value !== 'string' || !identifier.test(value)) { + errors.push(`${path} must use a stable lower-case key.`); + return ''; + } + return value; +} + +function isEntitySelector(value: unknown) { + if (!object(value) || typeof value.kind !== 'string') return false; + if (['agent', 'event_actor', 'event_target'].includes(value.kind)) return true; + return value.kind === 'event_field' && typeof value.path === 'string' && Boolean(value.path.trim()); +} + +function isReference(value: unknown): value is ValueReference { + if (!object(value) || typeof value.source !== 'string') return false; + switch (value.source) { + case 'event': + return typeof value.path === 'string' && Boolean(value.path.trim()); + case 'scenario': + return typeof value.key === 'string' && Boolean(value.key.trim()); + case 'combat': + return typeof value.path === 'string' + && Boolean(value.path.trim()) + && (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current'); + case 'equipment': + return typeof value.path === 'string' && Boolean(value.path.trim()); + case 'reducer': + return true; + case 'pool': + return typeof value.pool === 'string' + && Boolean(value.pool.trim()) + && (value.entity === undefined || isEntitySelector(value.entity)); + default: + return false; + } +} + +function isExpression(value: unknown): value is NumericExpression { + if (finite(value)) return true; + if (!object(value)) return false; + if ('ref' in value) return isReference(value.ref); + if (value.op === 'clamp') { + return isExpression(value.value) && isExpression(value.min) && isExpression(value.max); + } + return ['add', 'subtract', 'multiply', 'divide', 'min', 'max'].includes(String(value.op)) + && isExpression(value.left) + && isExpression(value.right); +} + +function isOperand(value: unknown) { + return scalar(value) || isReference(value); +} + +function isPredicate(value: unknown): value is Predicate { + if (!object(value) || typeof value.kind !== 'string') return false; + if (value.kind === 'true') return true; + if (value.kind === 'exists') return isReference(value.value); + if (value.kind === 'not') return isPredicate(value.predicate); + if (value.kind === 'all' || value.kind === 'any') { + return Array.isArray(value.predicates) && value.predicates.every(isPredicate); + } + return value.kind === 'compare' + && ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'].includes(String(value.operator)) + && isOperand(value.left) + && isOperand(value.right); +} + +function optionalGroupBy(value: Record) { + return value.groupBy === undefined || isReference(value.groupBy); +} + +function isReducer(value: unknown): value is Reducer { + if (!object(value) || typeof value.kind !== 'string') return false; + switch (value.kind) { + case 'none': + return true; + case 'count': + return finite(value.threshold) && optionalGroupBy(value); + case 'modulo': + return finite(value.every) && optionalGroupBy(value); + case 'distinct_count': + return finite(value.threshold) + && finite(value.windowSeconds) + && isReference(value.distinctBy); + case 'consecutive_count': + return finite(value.threshold) && optionalGroupBy(value); + case 'sliding_count': + return finite(value.threshold) && finite(value.windowSeconds) && optionalGroupBy(value); + case 'duration': + return finite(value.seconds) && optionalGroupBy(value); + case 'periodic': + return isExpression(value.intervalSeconds) && optionalGroupBy(value); + case 'derived_scalar': + return isExpression(value.value); + default: + return false; + } +} + +function isMutation(value: unknown): value is ClauseMutation { + if (!object(value) || typeof value.kind !== 'string') return false; + if (value.kind === 'none') return true; + if (value.kind === 'clear') { + return typeof value.pool === 'string' + && (value.entity === undefined || isEntitySelector(value.entity)); + } + if (value.kind === 'transfer') { + return typeof value.pool === 'string' + && isEntitySelector(value.from) + && isEntitySelector(value.to) + && (value.value === undefined || value.value === 'all' || isExpression(value.value)); + } + return ['add', 'set', 'consume', 'derive'].includes(value.kind) + && typeof value.pool === 'string' + && isExpression(value.value) + && (value.entity === undefined || isEntitySelector(value.entity)); +} + +function parseScenarioInputs(value: unknown, errors: string[]) { + if (!Array.isArray(value)) { + errors.push('Scenario inputs must be an array.'); + return [] as ScenarioInputDefinition[]; + } + const inputs: ScenarioInputDefinition[] = []; + for (const [index, candidate] of value.entries()) { + const path = `Scenario input ${index + 1}`; + if (!object(candidate)) { + errors.push(`${path} must be an object.`); + continue; + } + if ( + !key(candidate.key, `${path} key`, errors) + || typeof candidate.label !== 'string' + || !candidate.label.trim() + || !['toggle', 'slider', 'number'].includes(String(candidate.kind)) + ) { + errors.push(`${path} requires a label and supported input kind.`); + continue; + } + if ( + candidate.readiness !== undefined + && candidate.readiness !== 'ready' + && candidate.readiness !== 'requires_assumption' + ) { + errors.push(`${path} has an invalid readiness value.`); + continue; + } + inputs.push(candidate as ScenarioInputDefinition); + } + return inputs; +} + +function parsePools(value: unknown, errors: string[]) { + if (!Array.isArray(value)) { + errors.push('State pools must be an array.'); + return [] as ConditionalMechanicPoolDraft[]; + } + const pools: ConditionalMechanicPoolDraft[] = []; + for (const [index, candidate] of value.entries()) { + const path = `State pool ${index + 1}`; + if (!object(candidate)) { + errors.push(`${path} must be an object.`); + continue; + } + const poolKey = key(candidate.key, `${path} key`, errors); + if ( + !poolKey + || typeof candidate.label !== 'string' + || !candidate.label.trim() + || !stateKinds.has(candidate.stateKind as MechanicStateKind) + || !['agent', 'target'].includes(String(candidate.scope)) + || !finite(candidate.initial) + ) { + errors.push(`${path} requires a label, state kind, scope, and finite initial value.`); + continue; + } + for (const property of ['min', 'max', 'bestCaseValue', 'expiresAfterSeconds'] as const) { + if (candidate[property] !== undefined && !finite(candidate[property])) { + errors.push(`${path} ${property} must be a finite number.`); + } + } + pools.push(candidate as ConditionalMechanicPoolDraft); + } + return pools; +} + +function parseOutcome( + value: unknown, + path: string, + variantKeys: readonly string[], + allowedAttributes: ReadonlySet | undefined, + errors: string[] +): ConditionalMechanicOutcomeDraft | null { + if (!object(value)) { + errors.push(`${path} must be an object.`); + return null; + } + const outcomeKey = key(value.key, `${path} key`, errors); + if ( + !outcomeKey + || typeof value.label !== 'string' + || !value.label.trim() + || !phases.has(value.phase as OutcomePhase) + || !outcomeKinds.has(value.kind as MechanicOutcome['kind']) + || !scopes.has(value.scope as MechanicOutcomeScope) + || !bases.has(value.basis as OutcomeBasis) + ) { + errors.push(`${path} requires a label, phase, kind, scope, and value basis.`); + return null; + } + const kind = value.kind as MechanicOutcome['kind']; + if (kind === 'attribute_modifier') { + if (typeof value.attributeId !== 'string' || !value.attributeId.trim()) { + errors.push(`${path} attribute modifiers require an Attribute ID.`); + } else if (allowedAttributes && !allowedAttributes.has(value.attributeId)) { + errors.push(`${path} references unknown Attribute "${value.attributeId}".`); + } + } else if (value.attributeId !== undefined) { + errors.push(`${path} may only use an Attribute ID for an attribute modifier.`); + } + if (kind === 'restoration' || kind === 'resource_cost') { + if (!resources.has(value.resource as MechanicResource)) { + errors.push(`${path} ${kind.replace('_', ' ')} requires a resource.`); + } + } else if (value.resource !== undefined) { + errors.push(`${path} may only use a resource for restoration or resource cost.`); + } + if ( + value.combatTiming !== undefined + && value.combatTiming !== 'pre_event' + && value.combatTiming !== 'current' + ) { + errors.push(`${path} has invalid combat timing.`); + } + if ( + value.descriptionSlot !== undefined + && (!Number.isInteger(value.descriptionSlot) || Number(value.descriptionSlot) < 1) + ) { + errors.push(`${path} description slot must be a positive integer.`); + } + if (!object(value.variants)) { + errors.push(`${path} variants must be an object.`); + return null; + } + const actualKeys = Object.keys(value.variants).sort(); + const expectedKeys = [...variantKeys].sort(); + if ( + actualKeys.length !== expectedKeys.length + || actualKeys.some((variant, index) => variant !== expectedKeys[index]) + ) { + errors.push(`${path} must define exactly these variants: ${variantKeys.join(', ')}.`); + } + const variants: Record = {}; + for (const variant of variantKeys) { + const configured = value.variants[variant]; + if (!object(configured) || !finite(configured.value)) { + errors.push(`${path} variant "${variant}" requires a finite display value.`); + continue; + } + if (configured.expression !== undefined && !isExpression(configured.expression)) { + errors.push(`${path} variant "${variant}" has an invalid value expression.`); + continue; + } + variants[variant] = configured as ConditionalMechanicVariantValue; + } + return { + ...(value as unknown as ConditionalMechanicOutcomeDraft), + key: outcomeKey, + variants + }; +} + +function parseClauses( + value: unknown, + variantKeys: readonly string[], + options: ParseConditionalMechanicDraftOptions, + errors: string[] +) { + if (!Array.isArray(value)) { + errors.push('Mechanic clauses must be an array.'); + return [] as ConditionalMechanicClauseDraft[]; + } + const clauses: ConditionalMechanicClauseDraft[] = []; + for (const [index, candidate] of value.entries()) { + const path = `Clause ${index + 1}`; + if (!object(candidate)) { + errors.push(`${path} must be an object.`); + continue; + } + const clauseKey = key(candidate.key, `${path} key`, errors); + if (!clauseKey || !events.has(String(candidate.event))) { + errors.push(`${path} requires a supported direct event.`); + continue; + } + if ( + candidate.recipeKey !== undefined + && ( + typeof candidate.recipeKey !== 'string' + || !candidate.recipeKey.trim() + || (options.allowedRecipeKeys && !options.allowedRecipeKeys.has(candidate.recipeKey)) + ) + ) { + errors.push(`${path} has an unknown recipe key.`); + } + if (!isPredicate(candidate.if ?? { kind: 'true' })) { + errors.push(`${path} has an invalid predicate.`); + } + if (!isReducer(candidate.reducer)) errors.push(`${path} has an invalid reducer.`); + if (candidate.weight !== undefined && !isExpression(candidate.weight)) { + errors.push(`${path} has an invalid weight expression.`); + } + if ( + candidate.cooldownSeconds !== undefined + && (!finite(candidate.cooldownSeconds) || candidate.cooldownSeconds <= 0) + ) { + errors.push(`${path} cooldown must be greater than zero.`); + } + if (!Array.isArray(candidate.mutations) || !candidate.mutations.every(isMutation)) { + errors.push(`${path} mutations must be a valid array.`); + } + if (!Array.isArray(candidate.outcomes)) { + errors.push(`${path} outcomes must be an array.`); + continue; + } + const outcomes = candidate.outcomes + .map((outcome, outcomeIndex) => parseOutcome( + outcome, + `${path} outcome ${outcomeIndex + 1}`, + variantKeys, + options.allowedAttributeIds, + errors + )) + .filter((outcome): outcome is ConditionalMechanicOutcomeDraft => outcome !== null); + clauses.push({ + ...(candidate as unknown as ConditionalMechanicClauseDraft), + key: clauseKey, + event: candidate.event as MechanicEventKind, + if: (candidate.if ?? { kind: 'true' }) as Predicate, + reducer: candidate.reducer as Reducer, + mutations: Array.isArray(candidate.mutations) ? candidate.mutations as ClauseMutation[] : [], + outcomes + }); + } + return clauses; +} + +function programsForDraft( + draft: ConditionalMechanicDraftV1, + options: ParseConditionalMechanicDraftOptions +) { + const programs: Record = {}; + for (const variant of options.variantKeys) { + programs[variant] = { + id: `${options.programId ?? 'mechanic'}:${variant}`, + name: options.programName?.trim() || 'Conditional Mechanic', + scenarioInputs: draft.scenarioInputs, + pools: draft.pools.map(({ stateKind: _stateKind, ...pool }) => pool), + clauses: draft.clauses.map((clause): MechanicClause => ({ + id: clause.key, + ...(clause.label ? { label: clause.label } : {}), + when: clause.event, + ...(clause.recipeKey ? { recipeKey: clause.recipeKey } : {}), + ...(clause.if ? { if: clause.if } : {}), + reducer: clause.reducer, + ...(clause.cooldownSeconds === undefined + ? {} + : { cooldownSeconds: clause.cooldownSeconds }), + ...(clause.weight === undefined ? {} : { weight: clause.weight }), + mutations: clause.mutations, + outcomes: clause.outcomes.map(({ key: outcomeKey, variants, descriptionSlot: _slot, ...outcome }) => ({ + ...outcome, + id: outcomeKey, + value: variants[variant].expression ?? variants[variant].value + })) + })) + }; + } + return programs; +} + +/** + * Parses the browser-authored mechanic payload without evaluating text as code. + * Every branch is converted to each source variant and validated by the runtime. + * Malformed payloads return errors and never escape into page rendering as throws. + */ +export function parseConditionalMechanicDraft( + raw: unknown, + options: ParseConditionalMechanicDraftOptions +): ConditionalMechanicDraftResult { + const errors: string[] = []; + try { + if ( + !Array.isArray(options.variantKeys) + || !options.variantKeys.length + || options.variantKeys.some((variant) => !variant.trim()) + || new Set(options.variantKeys).size !== options.variantKeys.length + ) { + return { ok: false, errors: ['Variant keys must be unique non-empty strings.'] }; + } + const parsed = parseRaw(raw, errors); + if (!object(parsed)) return { ok: false, errors: errors.length ? errors : ['The conditional mechanic must be an object.'] }; + if (parsed.version !== CONDITIONAL_MECHANIC_DRAFT_VERSION) { + errors.push(`Conditional mechanic version ${CONDITIONAL_MECHANIC_DRAFT_VERSION} is required.`); + } + if (!['ready', 'requires_assumptions', 'captured_only'].includes(String(parsed.simulationReadiness))) { + errors.push('The conditional mechanic has invalid simulation readiness.'); + } + const scenarioInputs = parseScenarioInputs(parsed.scenarioInputs, errors); + const pools = parsePools(parsed.pools, errors); + const clauses = parseClauses(parsed.clauses, options.variantKeys, options, errors); + const draft: ConditionalMechanicDraftV1 = { + version: CONDITIONAL_MECHANIC_DRAFT_VERSION, + simulationReadiness: parsed.simulationReadiness as ConditionalMechanicDraftV1['simulationReadiness'], + scenarioInputs, + pools, + clauses + }; + const inputRequiresAssumption = scenarioInputs.some( + (input) => input.readiness === 'requires_assumption' + ); + if (draft.simulationReadiness === 'ready' && inputRequiresAssumption) { + errors.push('Simulation readiness must require assumptions while an input requires an assumption.'); + } + if (draft.simulationReadiness !== 'captured_only' && !clauses.length) { + errors.push('A simulation-ready mechanic requires at least one clause.'); + } + const duplicate = (values: string[], label: string) => { + const seen = new Set(); + for (const value of values) { + if (seen.has(value)) errors.push(`${label} "${value}" is duplicated.`); + seen.add(value); + } + }; + duplicate(scenarioInputs.map((input) => input.key), 'Scenario input'); + duplicate(pools.map((pool) => pool.key), 'State pool'); + duplicate(clauses.map((clause) => clause.key), 'Clause'); + for (const clause of clauses) { + duplicate(clause.outcomes.map((outcome) => outcome.key), `Clause "${clause.key}" outcome`); + } + if (errors.length) return { ok: false, errors }; + const programs = programsForDraft(draft, options); + for (const [variant, program] of Object.entries(programs)) { + let validation; + try { + validation = validateMechanicProgram(program); + } catch { + errors.push(`Variant "${variant}" contains a structure the mechanic runtime cannot validate.`); + continue; + } + if (!validation.ok) { + errors.push(...validation.errors.map((error) => `Variant "${variant}": ${error}`)); + } + } + return errors.length + ? { ok: false, errors } + : { ok: true, value: { draft, programs } }; + } catch { + return { + ok: false, + errors: ['The conditional mechanic could not be parsed safely.'] + }; + } +} + +export function blankConditionalMechanicDraft(): ConditionalMechanicDraftV1 { + return { + version: CONDITIONAL_MECHANIC_DRAFT_VERSION, + simulationReadiness: 'captured_only', + scenarioInputs: [], + pools: [], + clauses: [] + }; +} diff --git a/src/lib/calculations/mechanic-persistence.test.ts b/src/lib/calculations/mechanic-persistence.test.ts new file mode 100644 index 0000000..9bb59dc --- /dev/null +++ b/src/lib/calculations/mechanic-persistence.test.ts @@ -0,0 +1,420 @@ +import { describe, expect, it } from 'vitest'; +import { + adaptConditionalEffectProgram, + type ConditionalEffectClauseRow, + type ConditionalEffectOutcomeRow, + type ConditionalEffectRuleRow, + type ConditionalEffectStatePoolRow +} from './mechanic-persistence.js'; + +const rule = ( + overrides: Partial = {} +): ConditionalEffectRuleRow => ({ + id: 'rule-1', + source_type: 'talent', + source_id: 'talent-1', + configured: 1, + script_version: 1, + simulation_readiness: 'ready', + scenario_inputs_json: '[]', + ...overrides +}); + +const pool = ( + overrides: Partial = {} +): ConditionalEffectStatePoolRow => ({ + id: 'pool-1', + rule_id: 'rule-1', + state_key: 'effect', + label: 'Effect', + state_kind: 'stack', + state_scope: 'agent', + initial_value: 0, + minimum_value: 0, + maximum_value: 10, + best_case_value: 10, + duration_seconds: 10, + expiry_policy: 'clear_all', + refresh_policy: 'refresh', + parameters_json: '{}', + sort_order: 1, + ...overrides +}); + +const clause = ( + overrides: Partial = {} +): ConditionalEffectClauseRow => ({ + id: 'clause-1', + rule_id: 'rule-1', + clause_key: 'primary', + label: 'Primary', + trigger_id: 'trigger-1', + parameters_json: JSON.stringify({ hitsRequired: 9, windowSeconds: 3 }), + conditions_json: JSON.stringify({ kind: 'true' }), + reducer_json: JSON.stringify({ kind: 'none' }), + emission_kind: 'each_match', + state_pool_id: 'pool-1', + mutations_json: JSON.stringify([{ kind: 'add', pool: 'effect', value: 1 }]), + cooldown_seconds: null, + output_branch: 'active', + sort_order: 1, + active: 1, + ...overrides +}); + +const outcome = ( + overrides: Partial = {} +): ConditionalEffectOutcomeRow => ({ + id: 'outcome-1', + rule_id: 'rule-1', + clause_id: 'clause-1', + state_pool_id: 'pool-1', + effect_key: 'weapon-damage', + variant_key: 'I', + outcome_key: 'active', + application_kind: 'active_modifier', + effect_scope: 'equipped_weapon', + stat_attribute_id: 'weapon-damage', + value: 3, + value_expression_json: null, + value_basis: 'flat', + value_scaling: 'per_state_unit', + unit: '%', + damage_basis: null, + application_phase: 'while_active', + min_state_value: 1, + max_state_value: null, + description_slot: 1, + sort_order: 1, + ...overrides +}); + +describe('conditional-effect persistence adapter', () => { + it('adapts a migrated simple same-target rule without losing its typed mechanics', () => { + const result = adaptConditionalEffectProgram({ + rule: rule(), + statePools: [pool()], + clauses: [clause()], + outcomes: [outcome()], + triggers: [{ + id: 'trigger-1', + action_key: 'same_target_hits_within_window', + name: 'Same-target hits' + }] + }, { name: 'Superior Firepower', variantKey: 'I' }); + + expect(result).toMatchObject({ + status: 'READY', + simulationReadiness: 'ready', + errors: [] + }); + expect(result.program).toMatchObject({ + id: 'rule-1', + name: 'Superior Firepower', + pools: [{ + key: 'effect', + scope: 'agent', + initial: 0, + max: 10, + bestCaseValue: 10, + expiresAfterSeconds: 10 + }], + clauses: [{ + id: 'clause-1', + when: 'weapon_hit', + if: { + kind: 'exists', + value: { source: 'event', path: 'targetId' } + }, + reducer: { + kind: 'sliding_count', + threshold: 9, + windowSeconds: 3, + reset: 'zero', + groupBy: { source: 'event', path: 'targetId' } + }, + mutations: [{ kind: 'add', pool: 'effect', value: 1 }], + outcomes: [{ + id: 'outcome-1', + phase: 'while_active', + kind: 'attribute_modifier', + scope: 'equipped_weapon', + attributeId: 'weapon-damage', + value: 3, + basis: 'flat', + statePool: 'effect', + perStack: true, + minStacks: 1 + }] + }] + }); + }); + + it('adapts the Happy Survival branches, explicit cadence, health timing, and upgrade references', () => { + const upgradeScale = { + op: 'add', + left: 1, + right: { + op: 'divide', + left: { ref: { source: 'equipment', path: 'protocolTalentUpgrades' } }, + right: 5 + } + }; + const result = adaptConditionalEffectProgram({ + rule: rule({ + source_type: 'os_protocol_talent', + source_id: 'happy-survival', + simulation_readiness: 'requires_assumptions', + scenario_inputs_json: JSON.stringify([{ + key: 'restorationIntervalSeconds', + label: 'Restoration interval', + kind: 'number', + readiness: 'requires_assumption', + description: 'Enter the verified talent cadence.', + min: 0.01 + }]) + }), + statePools: [pool({ + state_key: 'nearby-enemies', + label: 'Nearby Enemies', + state_kind: 'count', + maximum_value: 20, + best_case_value: 5, + duration_seconds: null + })], + clauses: [ + clause({ + id: 'sample-nearby', + clause_key: 'sample-nearby', + label: 'Sample nearby enemies', + trigger_id: null, + event_kind: 'proximity_snapshot', + parameters_json: '{}', + reducer_json: JSON.stringify({ + kind: 'derived_scalar', + value: { ref: { source: 'event', path: 'values.enemiesWithinRadius' } } + }), + mutations_json: JSON.stringify([{ + kind: 'derive', + pool: 'nearby-enemies', + value: { ref: { source: 'reducer' } } + }]), + sort_order: 1 + }), + clause({ + id: 'restore-nearby', + clause_key: 'restore-nearby', + label: 'Restore by nearby enemies', + trigger_id: null, + event_kind: 'tick', + weight_expression_json: JSON.stringify({ + op: 'divide', + left: { ref: { source: 'scenario', key: 'restorationIntervalSeconds' } }, + right: 60 + }), + parameters_json: '{}', + reducer_json: JSON.stringify({ + kind: 'periodic', + intervalSeconds: { + ref: { + source: 'scenario', + key: 'restorationIntervalSeconds' + } + } + }), + mutations_json: JSON.stringify([{ kind: 'none' }]), + state_pool_id: null, + sort_order: 2 + }), + clause({ + id: 'shot-health-exchange', + clause_key: 'shot-health-exchange', + label: 'Shot health exchange', + trigger_id: null, + event_kind: 'shot_fired', + recipe_key: 'happy-survival-shot', + parameters_json: '{}', + mutations_json: JSON.stringify([{ kind: 'none' }]), + state_pool_id: null, + sort_order: 3 + }) + ], + outcomes: [ + outcome({ + id: 'restore-health', + clause_id: 'restore-nearby', + state_pool_id: 'pool-1', + effect_key: 'nearby-restoration', + variant_key: 'base', + application_kind: 'restore_health', + effect_scope: 'self', + stat_attribute_id: null, + value: 0.5, + value_expression_json: JSON.stringify({ + op: 'multiply', + left: { + op: 'clamp', + value: { + op: 'multiply', + left: { ref: { source: 'pool', pool: 'nearby-enemies' } }, + right: 0.5 + }, + min: 0, + max: 2.5 + }, + right: upgradeScale + }), + value_basis: 'max_health_percent', + resource_kind: 'health', + value_scaling: 'flat', + application_phase: 'after_event', + min_state_value: null, + sort_order: 1 + }), + outcome({ + id: 'health-cost', + clause_id: 'shot-health-exchange', + state_pool_id: null, + effect_key: 'shot-health-cost', + variant_key: 'base', + application_kind: 'resource_cost', + effect_scope: 'self', + stat_attribute_id: null, + value: 2, + value_basis: 'max_health_percent', + resource_kind: 'health', + value_scaling: 'flat', + application_phase: 'on_event', + min_state_value: null, + sort_order: 2 + }), + outcome({ + id: 'bonus-damage', + clause_id: 'shot-health-exchange', + state_pool_id: null, + effect_key: 'current-health-bonus-damage', + variant_key: 'base', + application_kind: 'direct_damage', + effect_scope: 'target', + stat_attribute_id: null, + value: 0.6, + value_expression_json: JSON.stringify({ + op: 'multiply', + left: 0.6, + right: upgradeScale + }), + value_basis: 'current_health_percent', + combat_timing: 'current', + value_scaling: 'flat', + damage_basis: 'flat', + application_phase: 'on_event', + min_state_value: null, + sort_order: 3 + }) + ], + triggers: [] + }, { name: 'Happy Survival', variantKey: 'base' }); + + expect(result.errors).toEqual([]); + expect(result.status).toBe('REQUIRES_ASSUMPTIONS'); + expect(result.program?.scenarioInputs?.[0]).toMatchObject({ + key: 'restorationIntervalSeconds', + readiness: 'requires_assumption' + }); + expect(result.program?.clauses.map(({ when }) => when)).toEqual([ + 'proximity_snapshot', + 'tick', + 'shot_fired' + ]); + expect(result.program?.clauses[1].weight).toEqual({ + op: 'divide', + left: { ref: { source: 'scenario', key: 'restorationIntervalSeconds' } }, + right: 60 + }); + expect(result.program?.clauses[2].recipeKey).toBe('happy-survival-shot'); + expect(result.program?.clauses[1].reducer).toEqual({ + kind: 'periodic', + intervalSeconds: { + ref: { + source: 'scenario', + key: 'restorationIntervalSeconds' + } + } + }); + const restoration = result.program?.clauses[1].outcomes?.[0]; + const shotOutcomes = result.program?.clauses[2].outcomes; + expect(restoration).toMatchObject({ + kind: 'restoration', + phase: 'after_event', + basis: 'max_health_percent', + value: expect.objectContaining({ + op: 'multiply', + right: expect.objectContaining({ + op: 'add', + right: expect.objectContaining({ + left: { + ref: { + source: 'equipment', + path: 'protocolTalentUpgrades' + } + } + }) + }) + }) + }); + expect(shotOutcomes).toEqual([ + expect.objectContaining({ + id: 'health-cost', + kind: 'resource_cost', + resource: 'health', + phase: 'on_event', + basis: 'max_health_percent', + value: 2 + }), + expect.objectContaining({ + id: 'bonus-damage', + kind: 'direct_damage', + phase: 'on_event', + basis: 'current_health_percent', + combatTiming: 'current', + value: expect.objectContaining({ + left: 0.6, + right: expect.objectContaining({ + right: expect.objectContaining({ + left: { + ref: { + source: 'equipment', + path: 'protocolTalentUpgrades' + } + } + }) + }) + }) + }) + ]); + }); + + it('returns INVALID instead of throwing when persisted JSON is malformed', () => { + expect(() => adaptConditionalEffectProgram({ + rule: rule({ scenario_inputs_json: '[not-json' }), + statePools: [pool()], + clauses: [clause({ mutations_json: '{broken' })], + outcomes: [outcome()], + triggers: [{ id: 'trigger-1', action_key: 'same_target_hits_within_window' }] + }, { variantKey: 'I' })).not.toThrow(); + + const result = adaptConditionalEffectProgram({ + rule: rule({ scenario_inputs_json: '[not-json' }), + statePools: [pool()], + clauses: [clause({ mutations_json: '{broken' })], + outcomes: [outcome()], + triggers: [{ id: 'trigger-1', action_key: 'same_target_hits_within_window' }] + }, { variantKey: 'I' }); + expect(result.status).toBe('INVALID'); + expect(result.program).toBeNull(); + expect(result.errors).toEqual(expect.arrayContaining([ + 'Rule scenario inputs contains malformed JSON.', + 'Clause 1 mutations contains malformed JSON.' + ])); + }); +}); diff --git a/src/lib/calculations/mechanic-persistence.ts b/src/lib/calculations/mechanic-persistence.ts new file mode 100644 index 0000000..f7f5efc --- /dev/null +++ b/src/lib/calculations/mechanic-persistence.ts @@ -0,0 +1,757 @@ +import { + MECHANIC_EVENT_KINDS, + validateMechanicProgram, + type ClauseMutation, + type MechanicClause, + type MechanicEventKind, + type MechanicOutcome, + type MechanicProgram, + type NumericExpression, + type OutcomeBasis, + type OutcomePhase, + type PoolDefinition, + type Predicate, + type Reducer, + type ScenarioInputDefinition +} from './mechanic.js'; + +type DatabaseValue = string | number | bigint | boolean | null | undefined; + +export type ConditionalEffectRuleRow = { + id: DatabaseValue; + source_type: DatabaseValue; + source_id: DatabaseValue; + configured: DatabaseValue; + script_version: DatabaseValue; + simulation_readiness: DatabaseValue; + scenario_inputs_json: DatabaseValue; +}; + +export type ConditionalEffectStatePoolRow = { + id: DatabaseValue; + rule_id: DatabaseValue; + state_key: DatabaseValue; + label: DatabaseValue; + state_kind: DatabaseValue; + state_scope: DatabaseValue; + initial_value: DatabaseValue; + minimum_value: DatabaseValue; + maximum_value: DatabaseValue; + best_case_value: DatabaseValue; + duration_seconds: DatabaseValue; + expiry_policy: DatabaseValue; + refresh_policy: DatabaseValue; + parameters_json: DatabaseValue; + sort_order: DatabaseValue; +}; + +export type ConditionalEffectClauseRow = { + id: DatabaseValue; + rule_id: DatabaseValue; + clause_key: DatabaseValue; + label: DatabaseValue; + trigger_id: DatabaseValue; + event_kind?: DatabaseValue; + recipe_key?: DatabaseValue; + parameters_json: DatabaseValue; + conditions_json: DatabaseValue; + reducer_json: DatabaseValue; + weight_expression_json?: DatabaseValue; + emission_kind: DatabaseValue; + state_pool_id: DatabaseValue; + mutations_json: DatabaseValue; + cooldown_seconds: DatabaseValue; + output_branch: DatabaseValue; + sort_order: DatabaseValue; + active: DatabaseValue; +}; + +export type ConditionalEffectOutcomeRow = { + id: DatabaseValue; + rule_id: DatabaseValue; + clause_id: DatabaseValue; + state_pool_id: DatabaseValue; + effect_key: DatabaseValue; + variant_key: DatabaseValue; + outcome_key: DatabaseValue; + application_kind: DatabaseValue; + effect_scope: DatabaseValue; + stat_attribute_id: DatabaseValue; + value: DatabaseValue; + value_expression_json: DatabaseValue; + value_basis: DatabaseValue; + resource_kind?: DatabaseValue; + combat_timing?: DatabaseValue; + value_scaling: DatabaseValue; + unit: DatabaseValue; + damage_basis: DatabaseValue; + application_phase: DatabaseValue; + min_state_value: DatabaseValue; + max_state_value: DatabaseValue; + description_slot: DatabaseValue; + sort_order: DatabaseValue; +}; + +export type TriggerActionMetadataRow = { + id: DatabaseValue; + action_key: DatabaseValue; + event_kind?: DatabaseValue; + name?: DatabaseValue; +}; + +export type MechanicPersistenceInput = { + rule: ConditionalEffectRuleRow; + statePools: ConditionalEffectStatePoolRow[]; + clauses: ConditionalEffectClauseRow[]; + outcomes: ConditionalEffectOutcomeRow[]; + triggers: TriggerActionMetadataRow[]; +}; + +export type MechanicPersistenceOptions = { + name?: string; + variantKey?: string; +}; + +export type MechanicPersistenceStatus = + | 'READY' + | 'REQUIRES_ASSUMPTIONS' + | 'CAPTURED_ONLY' + | 'INVALID'; + +export type MechanicPersistenceResult = { + status: MechanicPersistenceStatus; + simulationReadiness: 'ready' | 'requires_assumptions' | 'captured_only' | null; + program: MechanicProgram | null; + errors: string[]; + warnings: string[]; +}; + +type ParsedJson = { ok: true; value: unknown } | { ok: false }; + +const eventKinds = new Set(MECHANIC_EVENT_KINDS); +const object = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); +const finite = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); +const text = (value: DatabaseValue) => typeof value === 'string' ? value.trim() : ''; +const nullableText = (value: DatabaseValue) => { + const result = text(value); + return result || null; +}; +const number = (value: DatabaseValue) => { + if (typeof value === 'bigint') return Number(value); + if (typeof value === 'boolean' || value === null || value === undefined || value === '') return null; + const result = Number(value); + return Number.isFinite(result) ? result : null; +}; +const enabled = (value: DatabaseValue) => + value === true || value === 1 || value === 1n || value === '1'; + +function parseJson(value: DatabaseValue, path: string, errors: string[]): ParsedJson { + if (typeof value !== 'string') { + errors.push(`${path} must contain JSON text.`); + return { ok: false }; + } + try { + return { ok: true, value: JSON.parse(value) }; + } catch { + errors.push(`${path} contains malformed JSON.`); + return { ok: false }; + } +} + +function requiredText(value: DatabaseValue, path: string, errors: string[]) { + const result = text(value); + if (!result) errors.push(`${path} is required.`); + return result; +} + +function requiredNumber(value: DatabaseValue, path: string, errors: string[]) { + const result = number(value); + if (result === null) errors.push(`${path} must be a finite number.`); + return result ?? 0; +} + +function isNumericExpression(value: unknown): value is NumericExpression { + if (finite(value)) return true; + if (!object(value)) return false; + if ('ref' in value) { + if (!object(value.ref) || typeof value.ref.source !== 'string') return false; + const source = value.ref.source; + if (source === 'event' || source === 'combat' || source === 'equipment') { + return typeof value.ref.path === 'string'; + } + if (source === 'scenario') return typeof value.ref.key === 'string'; + if (source === 'reducer') return true; + if (source === 'pool') return typeof value.ref.pool === 'string'; + return false; + } + if (value.op === 'clamp') { + return isNumericExpression(value.value) + && isNumericExpression(value.min) + && isNumericExpression(value.max); + } + return ['add', 'subtract', 'multiply', 'divide', 'min', 'max'].includes(String(value.op)) + && isNumericExpression(value.left) + && isNumericExpression(value.right); +} + +function isPredicate(value: unknown): value is Predicate { + if (!object(value) || typeof value.kind !== 'string') return false; + if (value.kind === 'true') return true; + if (value.kind === 'exists') return object(value.value) && typeof value.value.source === 'string'; + if (value.kind === 'not') return isPredicate(value.predicate); + if (value.kind === 'all' || value.kind === 'any') { + return Array.isArray(value.predicates) && value.predicates.every(isPredicate); + } + return value.kind === 'compare' + && ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'].includes(String(value.operator)) + && 'left' in value + && 'right' in value; +} + +function isReducer(value: unknown): value is Reducer { + if (!object(value) || typeof value.kind !== 'string') return false; + if (value.kind === 'none') return true; + if (value.kind === 'count') return finite(value.threshold); + if (value.kind === 'modulo') return finite(value.every); + if (value.kind === 'distinct_count') { + return finite(value.threshold) + && finite(value.windowSeconds) + && object(value.distinctBy) + && typeof value.distinctBy.source === 'string'; + } + if (value.kind === 'consecutive_count') return finite(value.threshold); + if (value.kind === 'sliding_count') return finite(value.threshold) && finite(value.windowSeconds); + if (value.kind === 'duration') return finite(value.seconds); + if (value.kind === 'periodic') return isNumericExpression(value.intervalSeconds); + return value.kind === 'derived_scalar' && isNumericExpression(value.value); +} + +function isMutation(value: unknown): value is ClauseMutation { + if (!object(value) || typeof value.kind !== 'string') return false; + if (value.kind === 'none') return true; + if (value.kind === 'clear') return typeof value.pool === 'string'; + if (value.kind === 'transfer') { + return typeof value.pool === 'string' && object(value.from) && object(value.to) + && (value.value === undefined || value.value === 'all' || isNumericExpression(value.value)); + } + return ['add', 'set', 'consume', 'derive'].includes(value.kind) + && typeof value.pool === 'string' + && isNumericExpression(value.value); +} + +function parseScenarioInputs( + raw: DatabaseValue, + errors: string[] +): ScenarioInputDefinition[] { + const parsed = parseJson(raw, 'Rule scenario inputs', errors); + if (!parsed.ok) return []; + if (!Array.isArray(parsed.value)) { + errors.push('Rule scenario inputs must be a JSON array.'); + return []; + } + const inputs: ScenarioInputDefinition[] = []; + for (const [index, candidate] of parsed.value.entries()) { + const path = `Scenario input ${index + 1}`; + if (!object(candidate)) { + errors.push(`${path} must be an object.`); + continue; + } + if ( + typeof candidate.key !== 'string' + || typeof candidate.label !== 'string' + || !['toggle', 'slider', 'number'].includes(String(candidate.kind)) + ) { + errors.push(`${path} requires a key, label, and supported input kind.`); + continue; + } + inputs.push(candidate as ScenarioInputDefinition); + } + return inputs; +} + +type LegacyMapping = { + when: MechanicEventKind; + predicate?: Predicate; + reducer?: Reducer; +}; + +function legacyActionMapping( + action: string, + params: Record, + path: string, + errors: string[] +): LegacyMapping | null { + const eventValue = (field: string) => ({ source: 'event' as const, path: `values.${field}` }); + const targetExists: Predicate = { + kind: 'exists', + value: { source: 'event', path: 'targetId' } + }; + switch (action) { + case 'same_target_hits_within_window': { + const hits = params.hitsRequired; + const window = params.windowSeconds; + if (!Number.isInteger(hits) || Number(hits) <= 0 || !finite(window) || window <= 0) { + errors.push(`${path} has invalid same-target hit parameters.`); + return null; + } + return { + when: 'weapon_hit', + predicate: targetExists, + reducer: { + kind: 'sliding_count', + threshold: Number(hits), + windowSeconds: window, + reset: 'zero', + groupBy: { source: 'event', path: 'targetId' } + } + }; + } + case 'headshot': + return { + when: 'weapon_hit', + predicate: { + kind: 'compare', + left: eventValue('headshot'), + operator: 'eq', + right: true + } + }; + case 'headshot_kill': + return { + when: 'enemy_killed', + predicate: { + kind: 'compare', + left: eventValue('headshot'), + operator: 'eq', + right: true + } + }; + case 'weapon_kill': + return { when: 'enemy_killed' }; + case 'target_hit_within_distance': { + const meters = params.meters; + if (!finite(meters) || meters <= 0) { + errors.push(`${path} has an invalid distance parameter.`); + return null; + } + return { + when: 'weapon_hit', + predicate: { + kind: 'all', + predicates: [ + targetExists, + { + kind: 'compare', + left: eventValue('distanceMeters'), + operator: 'lte', + right: meters + } + ] + } + }; + } + case 'damage_taken': + return { when: 'health_damage_taken' }; + case 'weapon_swap': + return { when: 'weapon_swapped' }; + case 'reload': + return { when: 'reload_started' }; + default: + errors.push( + `${path} uses legacy action "${action}", which cannot be represented exactly by the mechanic runtime.` + ); + return null; + } +} + +function combinePredicates(left: Predicate | undefined, right: Predicate | undefined) { + if (!left || left.kind === 'true') return right; + if (!right || right.kind === 'true') return left; + return { kind: 'all' as const, predicates: [left, right] }; +} + +function resolveClauseMapping( + trigger: TriggerActionMetadataRow | null, + eventKind: DatabaseValue, + parameters: Record, + explicitPredicate: Predicate, + explicitReducer: Reducer, + path: string, + errors: string[] +) { + const directEvent = nullableText(eventKind) + ?? nullableText(trigger?.event_kind) + ?? (eventKinds.has(text(trigger?.action_key)) ? text(trigger?.action_key) : null); + if (directEvent) { + return { + when: directEvent as MechanicEventKind, + predicate: explicitPredicate, + reducer: explicitReducer + }; + } + if (!trigger) { + errors.push(`${path} requires a direct event or known Action Family recipe.`); + return null; + } + const legacy = legacyActionMapping(text(trigger.action_key), parameters, path, errors); + if (!legacy) return null; + return { + when: legacy.when, + predicate: combinePredicates(legacy.predicate, explicitPredicate), + reducer: explicitReducer.kind === 'none' + ? legacy.reducer ?? explicitReducer + : explicitReducer + }; +} + +function parsePool( + row: ConditionalEffectStatePoolRow, + ruleId: string, + index: number, + errors: string[] +): { id: string; definition: PoolDefinition } | null { + const path = `State pool ${index + 1}`; + if (text(row.rule_id) !== ruleId) { + errors.push(`${path} belongs to a different rule.`); + return null; + } + const id = requiredText(row.id, `${path} ID`, errors); + const key = requiredText(row.state_key, `${path} key`, errors); + const label = requiredText(row.label, `${path} label`, errors); + const scope = text(row.state_scope); + if (scope !== 'agent' && scope !== 'target') errors.push(`${path} has an invalid scope.`); + const initial = requiredNumber(row.initial_value, `${path} initial value`, errors); + const minimum = number(row.minimum_value); + const maximum = requiredNumber(row.maximum_value, `${path} maximum value`, errors); + const bestCase = number(row.best_case_value); + const duration = number(row.duration_seconds); + const parameters = parseJson(row.parameters_json, `${path} parameters`, errors); + if (parameters.ok && !object(parameters.value)) errors.push(`${path} parameters must be a JSON object.`); + return { + id, + definition: { + key, + label, + scope: scope === 'target' ? 'target' : 'agent', + initial, + ...(minimum === null ? {} : { min: minimum }), + max: maximum, + ...(bestCase === null ? {} : { bestCaseValue: bestCase }), + ...(duration === null ? {} : { expiresAfterSeconds: duration }) + } + }; +} + +function outcomeKind(value: string): MechanicOutcome['kind'] | null { + if (value === 'active_modifier') return 'attribute_modifier'; + if (value === 'direct_damage') return 'direct_damage'; + if (value === 'restoration' || ['restore_health', 'restore_armor', 'restore_ammo'].includes(value)) { + return 'restoration'; + } + if (value === 'resource_cost') return 'resource_cost'; + if (value === 'marker') return 'marker'; + return null; +} + +function outcomeScope(value: string): MechanicOutcome['scope'] | null { + if (value === 'self') return 'self'; + if (value === 'equipped_weapon') return 'equipped_weapon'; + if (value === 'target') return 'target'; + if (value === 'allies') return 'allies'; + if (value === 'self_and_allies') return 'self_and_allies'; + if (value === 'assigned_ability') return 'assigned_ability'; + if (value === 'all_skills') return 'all_skills'; + return null; +} + +function parseOutcome( + row: ConditionalEffectOutcomeRow, + ruleId: string, + poolKeys: Map, + index: number, + errors: string[] +): { clauseId: string | null; outcome: MechanicOutcome } | null { + const path = `Outcome ${index + 1}`; + if (text(row.rule_id) !== ruleId) { + errors.push(`${path} belongs to a different rule.`); + return null; + } + const id = requiredText(row.id, `${path} ID`, errors); + const label = requiredText(row.effect_key, `${path} effect key`, errors); + const kind = outcomeKind(text(row.application_kind)); + if (!kind) errors.push(`${path} has an unsupported application kind.`); + const scope = outcomeScope(text(row.effect_scope)); + if (!scope) { + errors.push(`${path} has an effect scope that cannot be represented without losing meaning.`); + } + const basis = text(row.value_basis); + if (!['flat', 'max_health_percent', 'current_health_percent', 'weapon_base_percent'].includes(basis)) { + errors.push(`${path} has an unsupported value basis.`); + } + const phase = text(row.application_phase); + if (!['while_active', 'on_event', 'after_event', 'on_enter', 'on_exit'].includes(phase)) { + errors.push(`${path} has an unsupported application phase.`); + } + let value: NumericExpression = requiredNumber(row.value, `${path} value`, errors); + if (row.value_expression_json !== null && row.value_expression_json !== undefined && row.value_expression_json !== '') { + const parsed = parseJson(row.value_expression_json, `${path} value expression`, errors); + if (parsed.ok) { + if (isNumericExpression(parsed.value)) value = parsed.value; + else errors.push(`${path} value expression is not a valid numeric expression.`); + } + } + const statePoolId = nullableText(row.state_pool_id); + const statePool = statePoolId ? poolKeys.get(statePoolId) : undefined; + if (statePoolId && !statePool) errors.push(`${path} references an unknown state pool.`); + const scaling = text(row.value_scaling); + if (scaling !== 'flat' && scaling !== 'per_state_unit') { + errors.push(`${path} has an unsupported value-scaling mode.`); + } + if (!kind || !scope) return null; + const legacyResource = text(row.application_kind) === 'restore_health' + ? 'health' + : text(row.application_kind) === 'restore_armor' + ? 'armor' + : text(row.application_kind) === 'restore_ammo' + ? 'ammo' + : text(row.application_kind) === 'resource_cost' + ? 'health' + : null; + const resource = nullableText(row.resource_kind) ?? legacyResource; + if ( + (kind === 'restoration' || kind === 'resource_cost') + && !['health', 'armor', 'ammo'].includes(String(resource)) + ) { + errors.push(`${path} requires an explicit health, armor, or ammo resource.`); + } + const combatTiming = nullableText(row.combat_timing); + if (combatTiming && combatTiming !== 'pre_event' && combatTiming !== 'current') { + errors.push(`${path} has invalid combat timing.`); + } + return { + clauseId: nullableText(row.clause_id), + outcome: { + id, + label, + phase: phase as OutcomePhase, + kind, + scope, + value, + basis: basis as OutcomeBasis, + ...(resource && ['health', 'armor', 'ammo'].includes(resource) + ? { resource: resource as 'health' | 'armor' | 'ammo' } + : {}), + ...(combatTiming === 'pre_event' || combatTiming === 'current' + ? { combatTiming } + : {}), + ...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id) + ? { attributeId: text(row.stat_attribute_id) } + : {}), + ...(statePool ? { statePool } : {}), + ...(scaling === 'per_state_unit' ? { perStack: true } : {}), + ...(number(row.min_state_value) === null ? {} : { minStacks: number(row.min_state_value) as number }), + ...(number(row.max_state_value) === null ? {} : { maxStacks: number(row.max_state_value) as number }) + } + }; +} + +/** + * Converts normalized conditional-effect rows into the runtime's typed mechanic + * program. Invalid persisted data is reported as `INVALID`; this function never + * throws for row or JSON validation failures. + */ +export function adaptConditionalEffectProgram( + input: MechanicPersistenceInput, + options: MechanicPersistenceOptions = {} +): MechanicPersistenceResult { + const errors: string[] = []; + const warnings: string[] = []; + const readiness = text(input.rule.simulation_readiness); + const simulationReadiness = ['ready', 'requires_assumptions', 'captured_only'].includes(readiness) + ? readiness as MechanicPersistenceResult['simulationReadiness'] + : null; + if (!simulationReadiness) errors.push('Rule simulation readiness is invalid.'); + const ruleId = requiredText(input.rule.id, 'Rule ID', errors); + const sourceType = requiredText(input.rule.source_type, 'Rule source type', errors); + const sourceId = requiredText(input.rule.source_id, 'Rule source ID', errors); + const scriptVersion = requiredNumber(input.rule.script_version, 'Rule script version', errors); + if (!Number.isInteger(scriptVersion) || scriptVersion <= 0) { + errors.push('Rule script version must be a positive integer.'); + } + const scenarioInputs = parseScenarioInputs(input.rule.scenario_inputs_json, errors); + + const parsedPools = input.statePools + .slice() + .sort((left, right) => (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0)) + .map((row, index) => parsePool(row, ruleId, index, errors)) + .filter((value): value is NonNullable => value !== null); + const poolKeys = new Map(parsedPools.map((pool) => [pool.id, pool.definition.key])); + const triggerMap = new Map( + input.triggers.map((trigger) => [text(trigger.id), trigger]) + ); + + const variantKeys = [...new Set(input.outcomes.map((row) => text(row.variant_key)).filter(Boolean))]; + let selectedVariant = options.variantKey?.trim() || ''; + if (!selectedVariant) { + if (variantKeys.length > 1) { + errors.push(`A variant key is required because the rule has variants: ${variantKeys.join(', ')}.`); + } else { + selectedVariant = variantKeys[0] ?? 'base'; + } + } else if (variantKeys.length && !variantKeys.includes(selectedVariant)) { + errors.push(`Variant "${selectedVariant}" does not exist for this rule.`); + } + + const selectedOutcomes = input.outcomes + .filter((row) => text(row.variant_key) === selectedVariant) + .slice() + .sort((left, right) => (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0)) + .map((row, index) => parseOutcome(row, ruleId, poolKeys, index, errors)) + .filter((value): value is NonNullable => value !== null); + const outcomesByClause = new Map(); + const unassignedOutcomes: MechanicOutcome[] = []; + for (const parsed of selectedOutcomes) { + if (!parsed.clauseId) { + unassignedOutcomes.push(parsed.outcome); + continue; + } + const current = outcomesByClause.get(parsed.clauseId) ?? []; + current.push(parsed.outcome); + outcomesByClause.set(parsed.clauseId, current); + } + + const activeRows = input.clauses + .filter((row) => enabled(row.active)) + .slice() + .sort((left, right) => (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0)); + if (unassignedOutcomes.length && activeRows.length !== 1) { + errors.push('Outcomes without a clause can only be adapted when the rule has exactly one active clause.'); + } + const clauses: MechanicClause[] = []; + for (const [index, row] of activeRows.entries()) { + const path = `Clause ${index + 1}`; + if (text(row.rule_id) !== ruleId) { + errors.push(`${path} belongs to a different rule.`); + continue; + } + const id = requiredText(row.id, `${path} ID`, errors); + const triggerId = nullableText(row.trigger_id); + const directEvent = nullableText(row.event_kind); + const trigger = triggerId ? triggerMap.get(triggerId) ?? null : null; + if (!directEvent && !trigger) { + errors.push(`${path} references unknown trigger "${triggerId}".`); + continue; + } + const parametersJson = parseJson(row.parameters_json, `${path} parameters`, errors); + const conditionsJson = parseJson(row.conditions_json, `${path} conditions`, errors); + const reducerJson = parseJson(row.reducer_json, `${path} reducer`, errors); + const mutationsJson = parseJson(row.mutations_json, `${path} mutations`, errors); + if (!parametersJson.ok || !conditionsJson.ok || !reducerJson.ok || !mutationsJson.ok) continue; + if (!object(parametersJson.value)) { + errors.push(`${path} parameters must be a JSON object.`); + continue; + } + if (!isPredicate(conditionsJson.value)) { + errors.push(`${path} conditions are not a valid predicate.`); + continue; + } + if (!isReducer(reducerJson.value)) { + errors.push(`${path} reducer is invalid.`); + continue; + } + if (!Array.isArray(mutationsJson.value) || !mutationsJson.value.every(isMutation)) { + errors.push(`${path} mutations must be an array of valid state mutations.`); + continue; + } + const mapping = resolveClauseMapping( + trigger, + row.event_kind, + parametersJson.value, + conditionsJson.value, + reducerJson.value, + path, + errors + ); + if (!mapping) continue; + const clauseOutcomes = outcomesByClause.get(id) ?? []; + if (activeRows.length === 1) clauseOutcomes.push(...unassignedOutcomes); + let weight: NumericExpression | undefined; + if ( + row.weight_expression_json !== null + && row.weight_expression_json !== undefined + && row.weight_expression_json !== '' + ) { + const parsedWeight = parseJson(row.weight_expression_json, `${path} weight`, errors); + if (parsedWeight.ok) { + if (isNumericExpression(parsedWeight.value)) weight = parsedWeight.value; + else errors.push(`${path} weight is not a valid numeric expression.`); + } + } + const recipeKey = nullableText(row.recipe_key); + clauses.push({ + id, + label: text(row.label) || text(row.clause_key) || undefined, + when: mapping.when, + ...(recipeKey ? { recipeKey } : {}), + ...(mapping.predicate && mapping.predicate.kind !== 'true' ? { if: mapping.predicate } : {}), + reducer: mapping.reducer, + ...(number(row.cooldown_seconds) === null + ? {} + : { cooldownSeconds: number(row.cooldown_seconds) as number }), + ...(weight === undefined ? {} : { weight }), + mutations: mutationsJson.value, + ...(clauseOutcomes.length ? { outcomes: clauseOutcomes } : {}) + }); + } + for (const clauseId of outcomesByClause.keys()) { + if (!activeRows.some((row) => text(row.id) === clauseId)) { + errors.push(`Outcomes reference missing or inactive clause "${clauseId}".`); + } + } + + const program: MechanicProgram = { + id: ruleId, + name: options.name?.trim() || `${sourceType}:${sourceId}`, + ...(scenarioInputs.length ? { scenarioInputs } : {}), + ...(parsedPools.length ? { pools: parsedPools.map((pool) => pool.definition) } : {}), + clauses + }; + if (errors.length) { + return { status: 'INVALID', simulationReadiness, program: null, errors, warnings }; + } + try { + const validation = validateMechanicProgram(program); + if (!validation.ok) { + return { + status: 'INVALID', + simulationReadiness, + program: null, + errors: validation.errors, + warnings + }; + } + } catch { + return { + status: 'INVALID', + simulationReadiness, + program: null, + errors: ['The persisted mechanic contains a structure that the runtime cannot validate.'], + warnings + }; + } + if (!enabled(input.rule.configured) || simulationReadiness === 'captured_only') { + warnings.push('The mechanic is retained for configuration and is not ready for simulation.'); + return { status: 'CAPTURED_ONLY', simulationReadiness, program, errors: [], warnings }; + } + const requiresAssumptions = simulationReadiness === 'requires_assumptions' + || scenarioInputs.some((item) => item.readiness === 'requires_assumption'); + return { + status: requiresAssumptions ? 'REQUIRES_ASSUMPTIONS' : 'READY', + simulationReadiness, + program, + errors: [], + warnings + }; +} diff --git a/src/lib/calculations/mechanic-recipes.test.ts b/src/lib/calculations/mechanic-recipes.test.ts new file mode 100644 index 0000000..e40a5a2 --- /dev/null +++ b/src/lib/calculations/mechanic-recipes.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest'; +import { + TRIGGER_ACTION_KEYS, + validateTriggerConfiguration, + type TriggerActionKey, + type TriggerConfiguration +} from './conditional.js'; +import { + createMechanicRecipeForTrigger, + MECHANIC_RECIPE_REGISTRY, + type MechanicRecipeSeed +} from './mechanic-recipes.js'; +import { validateMechanicProgram } from './mechanic.js'; + +const paramsByAction: Record = { + same_target_hits_within_window: { hitsRequired: 3, windowSeconds: 2 }, + distinct_enemies_within_window: { enemiesRequired: 3, windowSeconds: 4 }, + consecutive_same_target_hits: { hitsRequired: 4 }, + continuous_firing_duration: { seconds: 2.5 }, + while_firing: {}, + health_threshold_bands: { + bands: [ + { thresholdPercent: 25, outcomeKey: 'critical' }, + { thresholdPercent: 50, outcomeKey: 'damaged' } + ] + }, + headshot: {}, + headshot_kill: {}, + weapon_kill: {}, + target_hit_within_distance: { meters: 15 }, + damage_taken: {}, + weapon_swap: {}, + reload: {}, + within_distance: { meters: 10 }, + extra_health_active: {} +}; + +function triggerFor(action: TriggerActionKey): TriggerConfiguration { + const trigger = validateTriggerConfiguration(action, paramsByAction[action]); + if (!trigger.ok) throw new Error(trigger.errors.join(' ')); + return trigger.value; +} + +function seedFor(action: TriggerActionKey): MechanicRecipeSeed { + const trigger = triggerFor(action); + const result = createMechanicRecipeForTrigger(trigger); + if (result.status !== 'supported') throw new Error(result.reason); + return result.seed; +} + +describe('mechanic recipe registry', () => { + it('covers every trigger action with a supported seed or an explicit reason', () => { + expect(Object.keys(MECHANIC_RECIPE_REGISTRY).sort()).toEqual([...TRIGGER_ACTION_KEYS].sort()); + for (const action of TRIGGER_ACTION_KEYS) { + const result = createMechanicRecipeForTrigger(triggerFor(action)); + expect(['supported', 'unsupported']).toContain(result.status); + if (result.status === 'unsupported') expect(result.reason.trim()).not.toBe(''); + else expect(result.seed.clauses.every((clause) => clause.recipeKey === action)).toBe(true); + } + }); + + it('produces a valid editable program for every supported recipe without hidden placeholders', () => { + for (const action of TRIGGER_ACTION_KEYS) { + const seed = seedFor(action); + const validation = validateMechanicProgram({ + id: `recipe-${action}`, + name: `Recipe ${action}`, + ...seed + }); + expect(validation, action).toMatchObject({ ok: true }); + } + }); + + it('maps hit-family actions to same-target, distinct, and consecutive reducers', () => { + expect(seedFor('same_target_hits_within_window').clauses[0]).toMatchObject({ + when: 'weapon_hit', + if: { kind: 'exists', value: { source: 'event', path: 'targetId' } }, + reducer: { + kind: 'sliding_count', threshold: 3, windowSeconds: 2, reset: 'zero', + groupBy: { source: 'event', path: 'targetId' } + } + }); + expect(seedFor('distinct_enemies_within_window').clauses[0]).toMatchObject({ + when: 'weapon_hit', + reducer: { + kind: 'distinct_count', threshold: 3, windowSeconds: 4, + distinctBy: { source: 'event', path: 'targetId' } + } + }); + expect(seedFor('consecutive_same_target_hits').clauses[0]).toMatchObject({ + when: 'weapon_hit', + reducer: { + kind: 'consecutive_count', threshold: 4, + groupBy: { source: 'event', path: 'targetId' } + } + }); + }); + + it('models firing triggers as explicit lifecycle branches', () => { + const continuous = seedFor('continuous_firing_duration'); + expect(continuous.pools).toEqual([expect.objectContaining({ key: 'firing-active', max: 1 })]); + expect(continuous.clauses.map((clause) => clause.when)).toEqual([ + 'firing_started', 'firing_stopped', 'reload_started', 'tick' + ]); + expect(continuous.clauses.at(-1)).toMatchObject({ + reducer: { kind: 'duration', seconds: 2.5 }, + if: { kind: 'compare', left: { source: 'pool', pool: 'firing-active' }, operator: 'gt', right: 0 } + }); + expect(seedFor('while_firing').clauses.map((clause) => clause.when)).toEqual([ + 'firing_started', 'firing_stopped', 'reload_started' + ]); + }); + + it('models health bands and persistent distance and Extra Health state as clearable branches', () => { + const health = seedFor('health_threshold_bands'); + expect(health.pools?.map((pool) => pool.key)).toEqual(['health-band-1', 'health-band-2']); + expect(health.clauses.map((clause) => clause.id)).toEqual([ + 'select-health-band-1', 'select-health-band-2', 'clear-health-bands' + ]); + expect(health.clauses[1].if).toMatchObject({ + kind: 'all', + predicates: expect.arrayContaining([ + { kind: 'compare', left: { source: 'event', path: 'values.healthPercent' }, operator: 'lte', right: 50 }, + { kind: 'compare', left: { source: 'event', path: 'values.healthPercent' }, operator: 'gt', right: 25 } + ]) + }); + + expect(seedFor('target_hit_within_distance').clauses[0]).toMatchObject({ + when: 'weapon_hit', + if: { + kind: 'all', + predicates: expect.arrayContaining([ + { kind: 'exists', value: { source: 'event', path: 'targetId' } }, + { kind: 'compare', left: { source: 'event', path: 'values.distanceMeters' }, operator: 'lte', right: 15 } + ]) + } + }); + + const withinDistance = seedFor('within_distance'); + expect(withinDistance.clauses.map((clause) => clause.id)).toEqual([ + 'enter-target-within-distance', 'exit-target-within-distance' + ]); + expect(withinDistance.clauses[0].if).toEqual({ + kind: 'compare', left: { source: 'event', path: 'values.distanceMeters' }, operator: 'lte', right: 10 + }); + expect(withinDistance.clauses[1].if).toMatchObject({ kind: 'not' }); + + const extraHealth = seedFor('extra_health_active'); + expect(extraHealth.clauses.map((clause) => clause.id)).toEqual([ + 'enter-extra-health-active', 'exit-extra-health-active' + ]); + expect(extraHealth.clauses[0].if).toEqual({ + kind: 'compare', left: { source: 'event', path: 'values.extraHealthActive' }, operator: 'eq', right: true + }); + }); +}); diff --git a/src/lib/calculations/mechanic-recipes.ts b/src/lib/calculations/mechanic-recipes.ts new file mode 100644 index 0000000..21badaf --- /dev/null +++ b/src/lib/calculations/mechanic-recipes.ts @@ -0,0 +1,336 @@ +import type { + ConsecutiveHitsParams, + ContinuousFiringParams, + DistanceParams, + DistinctEnemiesParams, + HealthThresholdParams, + SameTargetHitsParams, + TriggerActionKey, + TriggerConfiguration, + TriggerParamsByAction +} from './conditional.js'; +import type { + MechanicClause, + MechanicProgram, + PoolDefinition, + Predicate, + ValueReference +} from './mechanic.js'; + +/** A partial program that an editor can merge into a new mechanic draft. */ +export type MechanicRecipeSeed = Pick; + +export type SupportedMechanicRecipe = { + status: 'supported'; + seed: MechanicRecipeSeed; +}; + +export type UnsupportedMechanicRecipe = { + status: 'unsupported'; + reason: string; +}; + +export type MechanicRecipeResult = SupportedMechanicRecipe | UnsupportedMechanicRecipe; + +export type MechanicRecipeDefinition = { + key: K; + createSeed: (params: TriggerParamsByAction[K]) => MechanicRecipeResult; +}; + +export type MechanicRecipeRegistry = { + [K in TriggerActionKey]: MechanicRecipeDefinition; +}; + +const eventValue = (field: string): ValueReference => ({ source: 'event', path: `values.${field}` }); +const targetExists: Predicate = { + kind: 'exists', + value: { source: 'event', path: 'targetId' } +}; +const noMutation = [{ kind: 'none' as const }]; + +const supported = (seed: MechanicRecipeSeed): SupportedMechanicRecipe => ({ status: 'supported', seed }); + +function directClause( + id: string, + when: MechanicClause['when'], + ifPredicate?: Predicate +): MechanicClause { + return { id, when, if: ifPredicate, reducer: { kind: 'none' }, mutations: noMutation }; +} + +function firingLifecycleSeed(activePool: string, includeDuration?: number): MechanicRecipeSeed { + const pools: PoolDefinition[] = [{ + key: activePool, + label: 'Firing active', + scope: 'agent', + initial: 0, + min: 0, + max: 1 + }]; + const clauses: MechanicClause[] = [ + { + id: 'start-firing', + label: 'Start firing lifecycle', + when: 'firing_started', + reducer: { kind: 'none' }, + mutations: [{ kind: 'set', pool: activePool, value: 1 }] + }, + { + id: 'stop-firing', + label: 'Stop firing lifecycle', + when: 'firing_stopped', + reducer: { kind: 'none' }, + mutations: [{ kind: 'clear', pool: activePool }] + }, + { + id: 'reload-stops-firing', + label: 'Reload stops firing lifecycle', + when: 'reload_started', + reducer: { kind: 'none' }, + mutations: [{ kind: 'clear', pool: activePool }] + } + ]; + if (includeDuration !== undefined) { + clauses.push({ + id: 'continuous-firing-duration', + label: 'Reach continuous firing duration', + when: 'tick', + if: { + kind: 'compare', + left: { source: 'pool', pool: activePool }, + operator: 'gt', + right: 0 + }, + reducer: { kind: 'duration', seconds: includeDuration }, + mutations: noMutation + }); + } + return { pools, clauses }; +} + +function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed { + const sortedBands = [...params.bands].sort((left, right) => left.thresholdPercent - right.thresholdPercent); + const pools: PoolDefinition[] = sortedBands.map((band, index) => ({ + key: `health-band-${index + 1}`, + label: `Health band: ${band.outcomeKey}`, + scope: 'agent', + initial: 0, + min: 0, + max: 1 + })); + const clauses: MechanicClause[] = sortedBands.map((band, index) => { + const lowerBound = sortedBands[index - 1]?.thresholdPercent; + const predicates: Predicate[] = [{ + kind: 'compare', + left: eventValue('healthPercent'), + operator: 'lte', + right: band.thresholdPercent + }]; + if (lowerBound !== undefined) { + predicates.push({ + kind: 'compare', + left: eventValue('healthPercent'), + operator: 'gt', + right: lowerBound + }); + } + return { + id: `select-health-band-${index + 1}`, + label: `Select ${band.outcomeKey} at ${band.thresholdPercent}% Health`, + when: 'health_damage_taken', + if: { kind: 'all', predicates }, + reducer: { kind: 'none' }, + mutations: pools.map((pool, poolIndex) => + poolIndex === index + ? { kind: 'set' as const, pool: pool.key, value: 1 } + : { kind: 'clear' as const, pool: pool.key } + ) + }; + }); + clauses.push({ + id: 'clear-health-bands', + label: 'Clear health bands above the highest threshold', + when: 'health_damage_taken', + if: { + kind: 'compare', + left: eventValue('healthPercent'), + operator: 'gt', + right: sortedBands.at(-1)?.thresholdPercent ?? 0 + }, + reducer: { kind: 'none' }, + mutations: pools.map((pool) => ({ kind: 'clear' as const, pool: pool.key })) + }); + return { pools, clauses }; +} + +function proximityLifecycleSeed( + pool: PoolDefinition, + activePredicate: Predicate, + label: string +): MechanicRecipeSeed { + return { + pools: [pool], + clauses: [ + { + id: `enter-${pool.key}`, + label: `Enter ${label}`, + when: 'proximity_snapshot', + if: activePredicate, + reducer: { kind: 'none' }, + mutations: [{ kind: 'set', pool: pool.key, value: 1 }] + }, + { + id: `exit-${pool.key}`, + label: `Exit ${label}`, + when: 'proximity_snapshot', + if: { kind: 'not', predicate: activePredicate }, + reducer: { kind: 'none' }, + mutations: [{ kind: 'clear', pool: pool.key }] + } + ] + }; +} + +export const MECHANIC_RECIPE_REGISTRY: Readonly = { + same_target_hits_within_window: { + key: 'same_target_hits_within_window', + createSeed: (params) => { + const value = params as SameTargetHitsParams; + return supported({ clauses: [{ + id: 'same-target-hit-window', + when: 'weapon_hit', + if: targetExists, + reducer: { + kind: 'sliding_count', threshold: value.hitsRequired, windowSeconds: value.windowSeconds, + reset: 'zero', groupBy: { source: 'event', path: 'targetId' } + }, + mutations: noMutation + }] }); + } + }, + distinct_enemies_within_window: { + key: 'distinct_enemies_within_window', + createSeed: (params) => { + const value = params as DistinctEnemiesParams; + return supported({ clauses: [{ + id: 'distinct-enemy-hit-window', when: 'weapon_hit', if: targetExists, + reducer: { + kind: 'distinct_count', threshold: value.enemiesRequired, windowSeconds: value.windowSeconds, + distinctBy: { source: 'event', path: 'targetId' } + }, + mutations: noMutation + }] }); + } + }, + consecutive_same_target_hits: { + key: 'consecutive_same_target_hits', + createSeed: (params) => { + const value = params as ConsecutiveHitsParams; + return supported({ clauses: [{ + id: 'consecutive-same-target-hits', when: 'weapon_hit', if: targetExists, + reducer: { + kind: 'consecutive_count', threshold: value.hitsRequired, + groupBy: { source: 'event', path: 'targetId' } + }, + mutations: noMutation + }] }); + } + }, + continuous_firing_duration: { + key: 'continuous_firing_duration', + createSeed: (params) => supported(firingLifecycleSeed('firing-active', (params as ContinuousFiringParams).seconds)) + }, + while_firing: { + key: 'while_firing', + createSeed: (_params) => supported(firingLifecycleSeed('firing-active')) + }, + health_threshold_bands: { + key: 'health_threshold_bands', + createSeed: (params) => supported(healthBandSeed(params as HealthThresholdParams)) + }, + headshot: { + key: 'headshot', + createSeed: (_params) => supported({ clauses: [directClause('headshot', 'weapon_hit', { + kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: true + })] }) + }, + headshot_kill: { + key: 'headshot_kill', + createSeed: (_params) => supported({ clauses: [directClause('headshot-kill', 'enemy_killed', { + kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: true + })] }) + }, + weapon_kill: { + key: 'weapon_kill', + createSeed: (_params) => supported({ clauses: [directClause('weapon-kill', 'enemy_killed')] }) + }, + target_hit_within_distance: { + key: 'target_hit_within_distance', + createSeed: (params) => { + const value = params as DistanceParams; + return supported({ clauses: [directClause('target-hit-within-distance', 'weapon_hit', { + kind: 'all', predicates: [targetExists, { + kind: 'compare', left: eventValue('distanceMeters'), operator: 'lte', right: value.meters + }] + })] }); + } + }, + damage_taken: { + key: 'damage_taken', + createSeed: (_params) => supported({ clauses: [directClause('damage-taken', 'health_damage_taken')] }) + }, + weapon_swap: { + key: 'weapon_swap', + createSeed: (_params) => supported({ clauses: [directClause('weapon-swap', 'weapon_swapped')] }) + }, + reload: { + key: 'reload', + createSeed: (_params) => supported({ clauses: [directClause('reload-started', 'reload_started')] }) + }, + within_distance: { + key: 'within_distance', + createSeed: (params) => { + const value = params as DistanceParams; + return supported(proximityLifecycleSeed( + { key: 'target-within-distance', label: 'Target within distance', scope: 'agent', initial: 0, min: 0, max: 1 }, + { kind: 'compare', left: eventValue('distanceMeters'), operator: 'lte', right: value.meters }, + `target within ${value.meters}m` + )); + } + }, + extra_health_active: { + key: 'extra_health_active', + createSeed: (_params) => supported(proximityLifecycleSeed( + { key: 'extra-health-active', label: 'Extra Health active', scope: 'agent', initial: 0, min: 0, max: 1 }, + { kind: 'compare', left: eventValue('extraHealthActive'), operator: 'eq', right: true }, + 'Extra Health active' + )) + } +}; + +/** + * Creates an editable mechanic fragment for parameters that have already been + * validated by the trigger family registry. + */ +export function createMechanicRecipe( + action: K, + params: TriggerParamsByAction[K] +): MechanicRecipeResult { + const definition = MECHANIC_RECIPE_REGISTRY[action] as MechanicRecipeDefinition; + const result = definition.createSeed(params); + if (result.status === 'unsupported') return result; + return { + status: 'supported', + seed: { + ...result.seed, + clauses: result.seed.clauses.map((clause) => ({ + ...clause, + recipeKey: action + })) + } + }; +} + +export function createMechanicRecipeForTrigger(trigger: TriggerConfiguration): MechanicRecipeResult { + return createMechanicRecipe(trigger.action, trigger.params); +} diff --git a/src/lib/calculations/mechanic.test.ts b/src/lib/calculations/mechanic.test.ts new file mode 100644 index 0000000..530a73d --- /dev/null +++ b/src/lib/calculations/mechanic.test.ts @@ -0,0 +1,903 @@ +import { describe, expect, it } from 'vitest'; +import { + evaluateMechanicProgram, + formatMechanicProgram, + validateMechanicProgram, + type MechanicEvent, + type MechanicProgram +} from './mechanic.js'; + +const custom = (encounterSeconds = 30, inputs: Record = {}) => ({ + mode: 'custom' as const, + encounterSeconds, + inputs +}); + +describe('structured mechanic runtime', () => { + it('models Superior Firepower as same-target sliding hits and one shared expiring stack pool', () => { + const superiorFirepower: MechanicProgram = { + id: 'superior-firepower', + name: 'Superior Firepower', + pools: [{ + key: 'relentless', + label: 'Relentless', + scope: 'agent', + initial: 0, + min: 0, + max: 10, + expiresAfterSeconds: 10 + }], + clauses: [{ + id: 'build-relentless', + label: 'Build Relentless', + when: 'weapon_hit', + if: { kind: 'exists', value: { source: 'event', path: 'targetId' } }, + reducer: { + kind: 'sliding_count', + threshold: 9, + windowSeconds: 3, + groupBy: { source: 'event', path: 'targetId' } + }, + mutations: [{ kind: 'add', pool: 'relentless', value: 1 }], + outcomes: [ + { + id: 'relentless-weapon-damage', + label: 'Weapon Damage', + phase: 'while_active', + kind: 'attribute_modifier', + basis: 'flat', + scope: 'equipped_weapon', + attributeId: 'weapon-damage', + value: 3, + statePool: 'relentless', + perStack: true + }, + { + id: 'relentless-critical-damage', + label: 'Critical Damage', + phase: 'while_active', + kind: 'attribute_modifier', + basis: 'flat', + scope: 'equipped_weapon', + attributeId: 'critical-damage', + value: 1, + statePool: 'relentless', + perStack: true + } + ] + }] + }; + const events: MechanicEvent[] = Array.from({ length: 18 }, (_, index) => ({ + kind: 'weapon_hit', + at: index * 0.2, + targetId: 'enemy-a' + })); + const result = evaluateMechanicProgram(superiorFirepower, events, custom(15)); + expect(result.status).toBe('OK'); + expect(result.emissions[0].at).toBeCloseTo(1.6); + expect(result.emissions[1].at).toBeCloseTo(3.4); + expect(result.finalState).toContainEqual(expect.objectContaining({ + pool: 'relentless', + value: 0, + expiresAt: null + })); + expect(result.outcomes.every((outcome) => outcome.attributeId !== null)).toBe(true); + expect(result.outcomes.map((outcome) => outcome.value)).toEqual([3, 1, 6, 2]); + expect(result.script).toContain('COUNT 9 MATCHES WITHIN 3s PER EVENT.targetId'); + expect(result.script).toContain('ADD 1 TO relentless'); + const maximum = evaluateMechanicProgram(superiorFirepower, [], { + mode: 'maximum', + encounterSeconds: 15 + }); + expect(maximum.finalState[0].value).toBe(10); + expect(maximum.outcomes.map((outcome) => outcome.value)).toEqual([30, 10]); + }); + + it('supports every-N-shot reducers without a bespoke action family', () => { + const program: MechanicProgram = { + id: 'every-three-shots', + name: 'Every Three Shots', + pools: [{ key: 'charges', label: 'Charges', scope: 'agent', initial: 0, max: 10 }], + clauses: [{ + id: 'charge', + when: 'shot_fired', + reducer: { kind: 'modulo', every: 3 }, + mutations: [{ kind: 'add', pool: 'charges', value: 1 }] + }] + }; + const events = Array.from({ length: 7 }, (_, index): MechanicEvent => ({ + kind: 'shot_fired', + at: index + })); + const result = evaluateMechanicProgram(program, events, custom(10)); + expect(result.emissions.map((emission) => emission.at)).toEqual([2, 5]); + expect(result.finalState[0].value).toBe(2); + }); + + it('supports duration and periodic reducers over deterministic timeline snapshots', () => { + const program: MechanicProgram = { + id: 'timed-movement', + name: 'Timed Movement', + pools: [ + { key: 'settled', label: 'Settled', scope: 'agent', initial: 0, max: 1 }, + { key: 'tempo', label: 'Tempo', scope: 'agent', initial: 0, max: 10 } + ], + clauses: [ + { + id: 'immobile-duration', + when: 'movement_snapshot', + if: { + kind: 'compare', + left: { source: 'event', path: 'values.state' }, + operator: 'eq', + right: 'immobile' + }, + reducer: { kind: 'duration', seconds: 3 }, + mutations: [{ kind: 'set', pool: 'settled', value: 1 }] + }, + { + id: 'moving-periodic', + when: 'movement_snapshot', + if: { + kind: 'compare', + left: { source: 'event', path: 'values.state' }, + operator: 'eq', + right: 'moving' + }, + reducer: { kind: 'periodic', intervalSeconds: 2 }, + mutations: [{ kind: 'add', pool: 'tempo', value: 1 }] + } + ] + }; + const result = evaluateMechanicProgram(program, [ + { kind: 'movement_snapshot', at: 0, values: { state: 'immobile' } }, + { kind: 'movement_snapshot', at: 2, values: { state: 'immobile' } }, + { kind: 'movement_snapshot', at: 3, values: { state: 'immobile' } }, + { kind: 'movement_snapshot', at: 5, values: { state: 'moving' } }, + { kind: 'movement_snapshot', at: 7, values: { state: 'moving' } }, + { kind: 'movement_snapshot', at: 9, values: { state: 'moving' } } + ], custom(12)); + expect(result.emissions.map(({ clauseId, at }) => ({ clauseId, at }))).toEqual([ + { clauseId: 'immobile-duration', at: 3 }, + { clauseId: 'moving-periodic', at: 7 }, + { clauseId: 'moving-periodic', at: 9 } + ]); + expect(result.finalState).toEqual([ + expect.objectContaining({ pool: 'settled', value: 1 }), + expect.objectContaining({ pool: 'tempo', value: 2 }) + ]); + }); + + it('emits outcomes in the declared event and state-transition phases', () => { + const program: MechanicProgram = { + id: 'phases', + name: 'Outcome Phases', + pools: [{ key: 'active', label: 'Active', scope: 'agent', initial: 0, max: 1 }], + clauses: [ + { + id: 'activate', + when: 'weapon_hit', + reducer: { kind: 'none' }, + mutations: [{ kind: 'set', pool: 'active', value: 1 }], + outcomes: [ + { + id: 'triggering-hit', + label: 'Triggering Hit', + phase: 'on_event', + kind: 'marker', + basis: 'flat', + scope: 'equipped_weapon', + value: 1 + }, + { + id: 'entered', + label: 'Entered', + phase: 'on_enter', + kind: 'marker', + basis: 'flat', + scope: 'self', + statePool: 'active', + value: 1 + }, + { + id: 'active-modifier', + label: 'Active Modifier', + phase: 'while_active', + kind: 'attribute_modifier', + basis: 'flat', + scope: 'equipped_weapon', + attributeId: 'weapon-damage', + statePool: 'active', + value: 10 + } + ] + }, + { + id: 'deactivate', + when: 'firing_stopped', + reducer: { kind: 'none' }, + mutations: [{ kind: 'clear', pool: 'active' }], + outcomes: [{ + id: 'exited', + label: 'Exited', + phase: 'on_exit', + kind: 'marker', + basis: 'flat', + scope: 'self', + statePool: 'active', + value: 1 + }] + } + ] + }; + const result = evaluateMechanicProgram(program, [ + { kind: 'weapon_hit', at: 1, targetId: 'enemy-a' }, + { kind: 'firing_stopped', at: 2 } + ], custom()); + expect(result.outcomes.map((outcome) => `${outcome.outcomeId}:${outcome.phase}`)).toEqual([ + 'triggering-hit:on_event', + 'entered:on_enter', + 'active-modifier:while_active', + 'exited:on_exit' + ]); + }); + + it('emits on-exit outcomes at pool expiry and applies their resource side effects', () => { + const program: MechanicProgram = { + id: 'expiry-exit', + name: 'Expiry Exit', + pools: [{ + key: 'active', + label: 'Active', + scope: 'agent', + initial: 0, + max: 1, + expiresAfterSeconds: 2 + }], + clauses: [{ + id: 'activate', + when: 'weapon_hit', + reducer: { kind: 'none' }, + mutations: [{ kind: 'set', pool: 'active', value: 1 }], + outcomes: [{ + id: 'expiry-health-cost', + label: 'Expiry Health Cost', + phase: 'on_exit', + kind: 'resource_cost', + scope: 'self', + resource: 'health', + basis: 'max_health_percent', + statePool: 'active', + value: 10 + }] + }] + }; + const result = evaluateMechanicProgram( + program, + [{ kind: 'weapon_hit', at: 1, targetId: 'enemy-a' }], + custom(10), + { maxHealth: 100, currentHealth: 100 } + ); + expect(result.outcomes).toMatchObject([{ + outcomeId: 'expiry-health-cost', + phase: 'on_exit', + at: 3, + appliedValue: 10, + combatBefore: { currentHealth: 100 }, + combatAfter: { currentHealth: 90 } + }]); + expect(result.finalCombat?.currentHealth).toBe(90); + expect(result.finalState).toContainEqual(expect.objectContaining({ + pool: 'active', + value: 0, + expiresAt: null + })); + }); + + it('uses a custom percentage as a weighted estimate and resolves 100% in maximum mode', () => { + const program: MechanicProgram = { + id: 'full-health-hit', + name: 'Full Health Hit', + pools: [{ + key: 'estimated-procs', + label: 'Estimated Procs', + scope: 'agent', + initial: 0 + }], + scenarioInputs: [{ + key: 'fullHealthHitPercent', + label: 'Hits received at full Health', + kind: 'slider', + defaultValue: 25, + bestCaseValue: 100, + min: 0, + max: 100 + }], + clauses: [{ + id: 'estimate-full-health-hits', + when: 'agent_hit', + reducer: { kind: 'none' }, + weight: { + op: 'divide', + left: { ref: { source: 'scenario', key: 'fullHealthHitPercent' } }, + right: 100 + }, + mutations: [{ kind: 'add', pool: 'estimated-procs', value: 1 }] + }] + }; + const events: MechanicEvent[] = [0, 5, 10, 15].map((at) => ({ kind: 'agent_hit', at })); + const estimate = evaluateMechanicProgram(program, events, custom(20, { fullHealthHitPercent: 25 })); + expect(estimate.estimatedActivationCount).toBe(1); + expect(estimate.emissions.every((emission) => emission.weight === 0.25)).toBe(true); + expect(estimate.emissions.map((emission) => emission.mutations[0].after)).toEqual([ + 0.25, + 0.5, + 0.75, + 1 + ]); + expect(estimate.finalState[0].value).toBe(1); + const maximum = evaluateMechanicProgram(program, events, { mode: 'maximum', encounterSeconds: 20 }); + expect(maximum.estimatedActivationCount).toBe(4); + expect(maximum.scenarioInputs.fullHealthHitPercent).toBe(100); + expect(maximum.finalState[0].value).toBe(4); + }); + + it('derives proximity stacks directly from a builder-controlled snapshot', () => { + const program: MechanicProgram = { + id: 'nearby-allies', + name: 'Nearby Allies', + pools: [{ + key: 'nearby-ally-stacks', + label: 'Nearby Ally Stacks', + scope: 'agent', + initial: 0, + min: 0, + max: 3, + bestCaseValue: 3 + }], + clauses: [{ + id: 'derive-nearby-allies', + when: 'proximity_snapshot', + reducer: { + kind: 'derived_scalar', + value: { ref: { source: 'event', path: 'values.alliesWithinRadius' } } + }, + mutations: [{ + kind: 'derive', + pool: 'nearby-ally-stacks', + value: { ref: { source: 'reducer' } } + }] + }] + }; + const result = evaluateMechanicProgram(program, [{ + kind: 'proximity_snapshot', + at: 0, + values: { alliesWithinRadius: 2 } + }], custom()); + expect(result.finalState[0].value).toBe(2); + const maximum = evaluateMechanicProgram(program, [], { mode: 'maximum', encounterSeconds: 30 }); + expect(maximum.finalState[0].value).toBe(3); + }); + + it('shares one movement charge pool across generation and consumption clauses', () => { + const program: MechanicProgram = { + id: 'mobile-charge', + name: 'Mobile Charge', + pools: [{ + key: 'mobile-charge', + label: 'Mobile Charge', + scope: 'agent', + initial: 0, + min: 0, + max: 20 + }], + clauses: [ + { + id: 'gain-moving', + when: 'movement_snapshot', + if: { + kind: 'compare', + left: { source: 'event', path: 'values.state' }, + operator: 'eq', + right: 'moving' + }, + reducer: { kind: 'none' }, + mutations: [{ kind: 'add', pool: 'mobile-charge', value: 1 }] + }, + { + id: 'gain-sprinting', + when: 'movement_snapshot', + if: { + kind: 'compare', + left: { source: 'event', path: 'values.state' }, + operator: 'eq', + right: 'sprinting' + }, + reducer: { kind: 'none' }, + mutations: [{ kind: 'add', pool: 'mobile-charge', value: 2 }] + }, + { + id: 'spend-on-shot', + when: 'shot_fired', + if: { + kind: 'compare', + left: { source: 'pool', pool: 'mobile-charge' }, + operator: 'gt', + right: 0 + }, + reducer: { kind: 'none' }, + mutations: [{ kind: 'consume', pool: 'mobile-charge', value: 1 }] + }, + { + id: 'spend-on-skill', + when: 'skill_deployed', + reducer: { kind: 'none' }, + mutations: [{ kind: 'consume', pool: 'mobile-charge', value: 10 }] + } + ] + }; + const result = evaluateMechanicProgram(program, [ + { kind: 'movement_snapshot', at: 0, values: { state: 'moving' } }, + { kind: 'movement_snapshot', at: 1, values: { state: 'sprinting' } }, + { kind: 'movement_snapshot', at: 2, values: { state: 'sprinting' } }, + { kind: 'shot_fired', at: 3 }, + { kind: 'skill_deployed', at: 4 } + ], custom()); + expect(result.finalState[0].value).toBe(0); + expect(result.emissions.map((emission) => emission.clauseId)).toEqual([ + 'gain-moving', + 'gain-sprinting', + 'gain-sprinting', + 'spend-on-shot', + 'spend-on-skill' + ]); + }); + + it('transfers a target-scoped mark to the target named by the death event', () => { + const program: MechanicProgram = { + id: 'transferring-mark', + name: 'Transferring Mark', + pools: [{ + key: 'mark', + label: 'Mark', + scope: 'target', + initial: 0, + min: 0, + max: 1 + }], + clauses: [ + { + id: 'apply-mark', + when: 'weapon_hit', + reducer: { kind: 'none' }, + mutations: [{ + kind: 'set', + pool: 'mark', + entity: { kind: 'event_target' }, + value: 1 + }] + }, + { + id: 'transfer-mark', + when: 'enemy_killed', + if: { + kind: 'compare', + left: { source: 'pool', pool: 'mark', entity: { kind: 'event_target' } }, + operator: 'gt', + right: 0 + }, + reducer: { kind: 'none' }, + mutations: [{ + kind: 'transfer', + pool: 'mark', + from: { kind: 'event_target' }, + to: { kind: 'event_field', path: 'values.transferTargetId' }, + value: 'all' + }] + } + ] + }; + const result = evaluateMechanicProgram(program, [ + { kind: 'weapon_hit', at: 1, targetId: 'enemy-a' }, + { + kind: 'enemy_killed', + at: 2, + targetId: 'enemy-a', + values: { transferTargetId: 'enemy-b' } + } + ], custom()); + expect(result.finalState).toEqual([ + expect.objectContaining({ entityId: 'enemy-a', value: 0 }), + expect.objectContaining({ entityId: 'enemy-b', value: 1 }) + ]); + }); + + it('models scaled OS Protocol restoration, an unscaled Health cost, and post-cost bonus damage', () => { + const upgradeScale = { + op: 'add' as const, + left: 1, + right: { + op: 'divide' as const, + left: { ref: { source: 'equipment' as const, path: 'protocolTalentUpgrades' } }, + right: 5 + } + }; + const program: MechanicProgram = { + id: 'os-protocol-health-engine', + name: 'OS Protocol Health Engine', + scenarioInputs: [{ + key: 'restorationIntervalSeconds', + label: 'Restoration interval', + description: 'The game cadence is not yet verified; Custom Scenario must supply it.', + kind: 'number', + readiness: 'requires_assumption', + min: 0.01 + }], + pools: [{ + key: 'nearby-enemies', + label: 'Nearby Enemies', + scope: 'agent', + initial: 0, + min: 0, + max: 20 + }], + clauses: [ + { + id: 'sample-nearby-enemies', + when: 'proximity_snapshot', + reducer: { + kind: 'derived_scalar', + value: { ref: { source: 'event', path: 'values.enemiesWithinRadius' } } + }, + mutations: [{ + kind: 'derive', + pool: 'nearby-enemies', + value: { ref: { source: 'reducer' } } + }] + }, + { + id: 'restore-nearby-enemies', + when: 'tick', + reducer: { + kind: 'periodic', + intervalSeconds: { + ref: { + source: 'scenario', + key: 'restorationIntervalSeconds' + } + } + }, + mutations: [{ kind: 'none' }], + outcomes: [{ + id: 'nearby-restoration', + label: 'Nearby Enemy Restoration', + phase: 'after_event', + kind: 'restoration', + scope: 'self', + resource: 'health', + basis: 'max_health_percent', + value: { + op: 'multiply', + left: { + op: 'clamp', + value: { + op: 'multiply', + left: { ref: { source: 'pool', pool: 'nearby-enemies' } }, + right: 0.5 + }, + min: 0, + max: 2.5 + }, + right: upgradeScale + } + }] + }, + { + id: 'shot-health-exchange', + when: 'shot_fired', + reducer: { kind: 'none' }, + mutations: [{ kind: 'none' }], + outcomes: [ + { + id: 'shot-health-cost', + label: 'Shot Health Cost', + phase: 'on_event', + kind: 'resource_cost', + scope: 'self', + resource: 'health', + basis: 'max_health_percent', + value: 2 + }, + { + id: 'shot-health-damage', + label: 'Current Health Bonus Damage', + phase: 'on_event', + kind: 'direct_damage', + scope: 'target', + basis: 'current_health_percent', + combatTiming: 'current', + value: { + op: 'multiply', + left: 0.6, + right: upgradeScale + } + } + ] + } + ] + }; + const events: MechanicEvent[] = [ + { kind: 'tick', at: 0 }, + { kind: 'shot_fired', at: 1 }, + { kind: 'proximity_snapshot', at: 2, values: { enemiesWithinRadius: 9 } }, + { kind: 'tick', at: 3 } + ]; + const result = evaluateMechanicProgram( + program, + events, + custom(10, { restorationIntervalSeconds: 3 }), + { maxHealth: 10_000, currentHealth: 10_000, weaponBaseDamage: 500 }, + { protocolTalentUpgrades: 5 } + ); + expect(result.status).toBe('OK'); + const cost = result.outcomes.find((outcome) => outcome.outcomeId === 'shot-health-cost'); + const damage = result.outcomes.find((outcome) => outcome.outcomeId === 'shot-health-damage'); + const restoration = result.outcomes.find((outcome) => outcome.outcomeId === 'nearby-restoration'); + expect(cost).toMatchObject({ + value: 2, + resolvedValue: 200, + appliedValue: 200 + }); + expect(damage).toMatchObject({ + value: 1.2, + resolvedValue: 117.6, + appliedValue: 117.6 + }); + expect(restoration).toMatchObject({ + value: 5, + resolvedValue: 500, + appliedValue: 200 + }); + expect(result.finalCombat?.currentHealth).toBe(10_000); + expect(result.equipment?.protocolTalentUpgrades).toBe(5); + + const preCostProgram = structuredClone(program); + const preCostDamage = preCostProgram.clauses[2].outcomes?.find( + (outcome) => outcome.id === 'shot-health-damage' + ); + if (preCostDamage) preCostDamage.combatTiming = 'pre_event'; + const preCost = evaluateMechanicProgram( + preCostProgram, + [{ kind: 'shot_fired', at: 1 }], + custom(10, { restorationIntervalSeconds: 3 }), + { maxHealth: 10_000, currentHealth: 10_000 }, + { protocolTalentUpgrades: 5 } + ); + expect(preCost.outcomes.find((outcome) => outcome.outcomeId === 'shot-health-damage')) + .toMatchObject({ resolvedValue: 120 }); + + const unresolved = evaluateMechanicProgram( + program, + [], + { mode: 'maximum', encounterSeconds: 10 }, + { maxHealth: 10_000, currentHealth: 10_000 }, + { protocolTalentUpgrades: 5 } + ); + expect(unresolved.readiness).toEqual({ + status: 'REQUIRES_ASSUMPTIONS', + missingInputs: ['restorationIntervalSeconds'] + }); + }); + + it('counts distinct values in a rolling window, resets consecutive runs, and applies named resources', () => { + const program: MechanicProgram = { + id: 'runtime-gaps', + name: 'Runtime gaps', + clauses: [ + { + id: 'distinct-targets', + recipeKey: 'distinct-enemies-within-window', + when: 'weapon_hit', + if: { kind: 'exists', value: { source: 'event', path: 'targetId' } }, + reducer: { + kind: 'distinct_count', + threshold: 3, + windowSeconds: 2, + distinctBy: { source: 'event', path: 'targetId' } + }, + mutations: [{ kind: 'none' }], + outcomes: [{ + id: 'distinct-marker', + label: 'Distinct target threshold', + phase: 'after_event', + kind: 'marker', + scope: 'all_skills', + basis: 'flat', + value: 1 + }] + }, + { + id: 'consecutive-targets', + when: 'shot_fired', + if: { kind: 'exists', value: { source: 'event', path: 'targetId' } }, + reducer: { + kind: 'consecutive_count', + threshold: 3, + groupBy: { source: 'event', path: 'targetId' } + }, + mutations: [{ kind: 'none' }], + outcomes: [{ + id: 'consecutive-marker', + label: 'Consecutive target threshold', + phase: 'after_event', + kind: 'marker', + scope: 'assigned_ability', + basis: 'flat', + value: 1 + }] + }, + { + id: 'resources', + when: 'tick', + reducer: { kind: 'none' }, + mutations: [{ kind: 'none' }], + outcomes: [ + { + id: 'health-cost', label: 'Health cost', phase: 'on_event', kind: 'resource_cost', + scope: 'self', resource: 'health', basis: 'flat', value: 10 + }, + { + id: 'armor-restore', label: 'Armor restore', phase: 'on_event', kind: 'restoration', + scope: 'self_and_allies', resource: 'armor', basis: 'flat', value: 20 + }, + { + id: 'ammo-restore', label: 'Ammo restore', phase: 'on_event', kind: 'restoration', + scope: 'equipped_weapon', resource: 'ammo', basis: 'flat', value: 5 + } + ] + } + ] + }; + const result = evaluateMechanicProgram(program, [ + { kind: 'weapon_hit', at: 0, targetId: 'alpha' }, + { kind: 'weapon_hit', at: 0.2, targetId: 'alpha' }, + { kind: 'weapon_hit', at: 0.4, targetId: 'bravo' }, + { kind: 'weapon_hit', at: 0.6, targetId: 'charlie' }, + { kind: 'shot_fired', at: 1, targetId: 'alpha' }, + { kind: 'shot_fired', at: 1.1, targetId: 'alpha' }, + { kind: 'shot_fired', at: 1.2 }, + { kind: 'shot_fired', at: 1.3, targetId: 'bravo' }, + { kind: 'shot_fired', at: 1.4, targetId: 'bravo' }, + { kind: 'shot_fired', at: 1.5, targetId: 'bravo' }, + { kind: 'tick', at: 2 } + ], custom(), { maxHealth: 100, currentHealth: 50, maxArmor: 50, currentArmor: 30, maxAmmo: 10, currentAmmo: 7 }); + expect(result.status).toBe('OK'); + expect(result.emissions.filter(({ clauseId }) => clauseId === 'distinct-targets')).toMatchObject([{ at: 0.6, reducerValue: 3 }]); + expect(result.emissions.filter(({ clauseId }) => clauseId === 'consecutive-targets')).toMatchObject([{ at: 1.5, reducerValue: 3 }]); + expect(result.outcomes.filter(({ clauseId }) => clauseId === 'resources')).toMatchObject([ + { outcomeId: 'health-cost', resource: 'health', appliedValue: 10 }, + { outcomeId: 'armor-restore', resource: 'armor', appliedValue: 20 }, + { outcomeId: 'ammo-restore', resource: 'ammo', appliedValue: 3 } + ]); + expect(result.finalCombat).toMatchObject({ currentHealth: 40, currentArmor: 50, currentAmmo: 10 }); + expect(result.script).toContain('COUNT 3 DISTINCT EVENT.targetId WITHIN 2s'); + expect(result.script).toContain('COUNT 3 CONSECUTIVE MATCHES PER EVENT.targetId'); + expect(result.script).toContain('[RECIPE distinct-enemies-within-window]'); + }); +}); + +describe('mechanic validation and pseudo-script', () => { + it('enforces clause cooldowns without counting events received during the cooldown', () => { + const program: MechanicProgram = { + id: 'cooldown', + name: 'Cooldown', + clauses: [{ + id: 'shot-proc', + when: 'shot_fired', + reducer: { kind: 'none' }, + cooldownSeconds: 2, + mutations: [{ kind: 'none' }], + outcomes: [{ + id: 'proc', + label: 'Proc', + phase: 'after_event', + kind: 'marker', + scope: 'self', + basis: 'flat', + value: 1 + }] + }] + }; + const result = evaluateMechanicProgram(program, [ + { kind: 'shot_fired', at: 0 }, + { kind: 'shot_fired', at: 1 }, + { kind: 'shot_fired', at: 2 }, + { kind: 'shot_fired', at: 3 } + ], custom(4)); + expect(result.emissions.map(({ at }) => at)).toEqual([0, 2]); + expect(result.outcomes.map(({ at }) => at)).toEqual([0, 2]); + expect(result.script).toContain('COOLDOWN 2s'); + }); + + it('rejects invalid references, reducers, and target transfer scopes', () => { + const invalid: MechanicProgram = { + id: 'invalid', + name: 'Invalid', + pools: [{ key: 'agent-state', label: 'Agent State', scope: 'agent', initial: 0 }], + clauses: [{ + id: 'bad', + recipeKey: '', + when: 'shot_fired', + reducer: { kind: 'modulo', every: 0 }, + mutations: [{ + kind: 'transfer', + pool: 'agent-state', + from: { kind: 'agent' }, + to: { kind: 'event_target' } + }], + outcomes: [{ + id: 'missing-attribute', + label: 'Missing', + phase: 'after_event', + kind: 'attribute_modifier', + basis: 'flat', + scope: 'self', + value: { ref: { source: 'scenario', key: 'missing-input' } } + }, { + id: 'missing-resource', + label: 'Missing resource', + phase: 'after_event', + kind: 'restoration', + scope: 'self', + basis: 'flat', + value: 1 + }] + }] + }; + const result = validateMechanicProgram(invalid); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain('Clause 1 modulo interval must be a positive integer.'); + expect(result.errors).toContain('Clause 1 recipe key cannot be empty.'); + expect(result.errors).toContain('Clause 1 mutation 1 can only transfer a target-scoped pool.'); + expect(result.errors).toContain('Clause 1 outcome 1 requires an Attribute ID.'); + expect(result.errors).toContain('Clause 1 outcome 1 value references unknown scenario input "missing-input".'); + expect(result.errors).toContain('Clause 1 outcome 2 requires a resource.'); + } + }); + + it('formats nested conditions and state operations deterministically', () => { + const program: MechanicProgram = { + id: 'format', + name: 'Prepared', + pools: [{ key: 'prepared', label: 'Prepared', scope: 'agent', initial: 0, max: 10 }], + clauses: [{ + id: 'gain', + label: 'Gain Prepared', + when: 'agent_hit', + if: { + kind: 'all', + predicates: [ + { + kind: 'compare', + left: { source: 'event', path: 'values.healthPercentBefore' }, + operator: 'eq', + right: 100 + }, + { kind: 'not', predicate: { kind: 'exists', value: { source: 'event', path: 'values.blocked' } } } + ] + }, + reducer: { kind: 'none' }, + mutations: [{ kind: 'add', pool: 'prepared', value: 1 }] + }] + }; + expect(formatMechanicProgram(program)).toBe([ + 'MECHANIC PREPARED', + 'STATE prepared [AGENT] 0..10', + '', + 'WHEN AGENT HIT // Gain Prepared', + 'IF (EVENT.values.healthPercentBefore == 100) AND (NOT (EXISTS EVENT.values.blocked))', + 'ADD 1 TO prepared' + ].join('\n')); + }); +}); diff --git a/src/lib/calculations/mechanic.ts b/src/lib/calculations/mechanic.ts new file mode 100644 index 0000000..375c7c2 --- /dev/null +++ b/src/lib/calculations/mechanic.ts @@ -0,0 +1,1743 @@ +export const MECHANIC_EVENT_KINDS = [ + 'shot_fired', + 'weapon_hit', + 'skill_deployed', + 'skill_hit', + 'agent_hit', + 'health_damage_taken', + 'damage_dealt', + 'enemy_killed', + 'assist', + 'healing_applied', + 'extra_health_granted', + 'movement_snapshot', + 'proximity_snapshot', + 'firing_started', + 'firing_stopped', + 'reload_started', + 'reload_completed', + 'weapon_swapped', + 'tick' +] as const; + +export type MechanicEventKind = (typeof MECHANIC_EVENT_KINDS)[number]; +export type MechanicEventDefinition = { + key: MechanicEventKind; + label: string; + description: string; +}; + +export const MECHANIC_EVENT_REGISTRY: Readonly> = { + shot_fired: { + key: 'shot_fired', + label: 'Shot fired', + description: 'A round leaves the equipped weapon; hit confirmation is not required.' + }, + weapon_hit: { + key: 'weapon_hit', + label: 'Weapon hit', + description: 'A weapon attack connects with a target. Critical and distance details belong in event values.' + }, + skill_deployed: { + key: 'skill_deployed', + label: 'Skill deployed', + description: 'The Agent deploys or activates a specialization ability.' + }, + skill_hit: { + key: 'skill_hit', + label: 'Skill hit', + description: 'A deployed skill connects with a target. Critical and distance details belong in event values.' + }, + agent_hit: { + key: 'agent_hit', + label: 'Agent hit', + description: 'An incoming attack connects, including hits absorbed entirely by Extra Health.' + }, + health_damage_taken: { + key: 'health_damage_taken', + label: 'Health damage taken', + description: 'The Agent loses Health. Damage absorbed entirely by Extra Health does not emit this event.' + }, + damage_dealt: { + key: 'damage_dealt', + label: 'Damage dealt', + description: 'The Agent deals damage; source, target, distance, and critical details belong in event values.' + }, + enemy_killed: { + key: 'enemy_killed', + label: 'Enemy killed', + description: 'An enemy dies. Weapon or skill source may be supplied when a mechanic requires the distinction.' + }, + assist: { + key: 'assist', + label: 'Assist', + description: 'The Agent receives credit for assisting with an enemy defeat.' + }, + healing_applied: { + key: 'healing_applied', + label: 'Healing applied', + description: 'Health is restored; source and recipient details identify giving versus receiving healing.' + }, + extra_health_granted: { + key: 'extra_health_granted', + label: 'Extra Health granted', + description: 'The Agent grants Extra Health to a recipient.' + }, + movement_snapshot: { + key: 'movement_snapshot', + label: 'Movement snapshot', + description: 'A sampled Agent movement state such as immobile, moving, or sprinting.' + }, + proximity_snapshot: { + key: 'proximity_snapshot', + label: 'Proximity snapshot', + description: 'A sampled count or state for nearby allies, enemies, and Agent-owned buffs.' + }, + firing_started: { + key: 'firing_started', + label: 'Firing started', + description: 'The equipped weapon begins an uninterrupted firing segment.' + }, + firing_stopped: { + key: 'firing_stopped', + label: 'Firing stopped', + description: 'The equipped weapon ends an uninterrupted firing segment.' + }, + reload_started: { + key: 'reload_started', + label: 'Reload started', + description: 'The equipped weapon begins reloading.' + }, + reload_completed: { + key: 'reload_completed', + label: 'Reload completed', + description: 'The equipped weapon finishes reloading.' + }, + weapon_swapped: { + key: 'weapon_swapped', + label: 'Weapon swapped', + description: 'The Agent changes the equipped active weapon.' + }, + tick: { + key: 'tick', + label: 'Simulation tick', + description: 'A deterministic timeline sample used by duration and periodic mechanics.' + } +}; + +export type Scalar = string | number | boolean | null; +export type StateScope = 'agent' | 'target'; +export type CalculationMode = 'maximum' | 'custom'; +export type OutcomePhase = 'on_event' | 'after_event' | 'on_enter' | 'on_exit' | 'while_active'; + +export type MechanicEvent = { + kind: MechanicEventKind; + at: number; + actorId?: string | null; + targetId?: string | null; + values?: Record; +}; + +export type ScenarioInputDefinition = { + key: string; + label: string; + kind: 'toggle' | 'slider' | 'number'; + readiness?: 'ready' | 'requires_assumption'; + description?: string; + defaultValue?: number | boolean; + bestCaseValue?: number | boolean; + min?: number; + max?: number; +}; + +export type MechanicScenario = + | { mode: 'maximum'; encounterSeconds: number } + | { mode: 'custom'; encounterSeconds: number; inputs?: Record }; + +export type EntitySelector = + | { kind: 'agent' } + | { kind: 'event_actor' } + | { kind: 'event_target' } + | { kind: 'event_field'; path: string }; + +export type ValueReference = + | { source: 'event'; path: string } + | { source: 'scenario'; key: string } + | { source: 'combat'; path: string; timing?: 'pre_event' | 'current' } + | { source: 'equipment'; path: string } + | { source: 'reducer' } + | { source: 'pool'; pool: string; entity?: EntitySelector }; + +export type NumericExpression = + | number + | { ref: ValueReference } + | { + op: 'add' | 'subtract' | 'multiply' | 'divide' | 'min' | 'max'; + left: NumericExpression; + right: NumericExpression; + } + | { op: 'clamp'; value: NumericExpression; min: NumericExpression; max: NumericExpression }; + +export type Predicate = + | { kind: 'true' } + | { kind: 'exists'; value: ValueReference } + | { + kind: 'compare'; + left: ValueReference | Scalar; + operator: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte'; + right: ValueReference | Scalar; + } + | { kind: 'all'; predicates: Predicate[] } + | { kind: 'any'; predicates: Predicate[] } + | { kind: 'not'; predicate: Predicate }; + +export type Reducer = + | { kind: 'none' } + | { kind: 'count'; threshold: number; reset?: 'zero' | 'subtract' | 'never'; groupBy?: ValueReference } + | { kind: 'modulo'; every: number; groupBy?: ValueReference } + | { kind: 'distinct_count'; threshold: number; windowSeconds: number; distinctBy: ValueReference } + | { kind: 'consecutive_count'; threshold: number; groupBy?: ValueReference } + | { + kind: 'sliding_count'; + threshold: number; + windowSeconds: number; + reset?: 'zero' | 'subtract' | 'never'; + groupBy?: ValueReference; + } + | { kind: 'duration'; seconds: number; repeat?: boolean; groupBy?: ValueReference } + | { kind: 'periodic'; intervalSeconds: NumericExpression; immediate?: boolean; groupBy?: ValueReference } + | { kind: 'derived_scalar'; value: NumericExpression }; + +export type PoolDefinition = { + key: string; + label: string; + scope: StateScope; + initial: number; + min?: number; + max?: number; + bestCaseValue?: number; + expiresAfterSeconds?: number; +}; + +type PoolMutationBase = { + pool: string; + entity?: EntitySelector; +}; + +export type ClauseMutation = + | { kind: 'none' } + | (PoolMutationBase & { kind: 'add' | 'set' | 'consume' | 'derive'; value: NumericExpression }) + | (PoolMutationBase & { kind: 'clear' }) + | { + kind: 'transfer'; + pool: string; + from: EntitySelector; + to: EntitySelector; + value?: NumericExpression | 'all'; + clearSource?: boolean; + }; + +export type OutcomeBasis = + | 'flat' + | 'max_health_percent' + | 'current_health_percent' + | 'weapon_base_percent'; + +export type MechanicOutcomeScope = + | 'self' + | 'equipped_weapon' + | 'target' + | 'allies' + | 'self_and_allies' + | 'assigned_ability' + | 'all_skills'; + +export type MechanicResource = 'health' | 'armor' | 'ammo'; + +export type MechanicOutcome = { + id: string; + label: string; + phase: OutcomePhase; + kind: 'attribute_modifier' | 'direct_damage' | 'restoration' | 'resource_cost' | 'marker'; + scope: MechanicOutcomeScope; + value: NumericExpression; + basis: OutcomeBasis; + resource?: MechanicResource; + combatTiming?: 'pre_event' | 'current'; + attributeId?: string; + statePool?: string; + perStack?: boolean; + minStacks?: number; + maxStacks?: number; +}; + +export type MechanicClause = { + id: string; + label?: string; + recipeKey?: string; + when: MechanicEventKind; + if?: Predicate; + reducer: Reducer; + cooldownSeconds?: number; + weight?: NumericExpression; + mutations: ClauseMutation[]; + outcomes?: MechanicOutcome[]; +}; + +export type MechanicProgram = { + id: string; + name: string; + scenarioInputs?: ScenarioInputDefinition[]; + pools?: PoolDefinition[]; + clauses: MechanicClause[]; +}; + +export type PoolSnapshot = { + pool: string; + scope: StateScope; + entityId: string; + value: number; + expiresAt: number | null; +}; + +export type MutationRecord = { + kind: ClauseMutation['kind']; + pool?: string; + entityId?: string; + before?: number; + after?: number; + targetEntityId?: string; + targetBefore?: number; + targetAfter?: number; +}; + +export type MechanicEmission = { + clauseId: string; + at: number; + weight: number; + reducerValue: number; + mutations: MutationRecord[]; +}; + +export type MechanicOutcomeEmission = { + clauseId: string; + outcomeId: string; + at: number; + phase: OutcomePhase; + kind: MechanicOutcome['kind']; + scope: MechanicOutcome['scope']; + resource: MechanicResource | null; + attributeId: string | null; + basis: OutcomeBasis; + combatTiming: 'pre_event' | 'current'; + value: number; + resolvedValue: number; + appliedValue: number; + weight: number; + stateValue: number; + combatBefore: MechanicCombatContext | null; + combatAfter: MechanicCombatContext | null; +}; + +export type MechanicEvaluation = { + status: 'OK' | 'INVALID'; + errors: string[]; + programId: string; + mode: CalculationMode; + scenarioInputs: Record; + readiness: { + status: 'READY' | 'REQUIRES_ASSUMPTIONS'; + missingInputs: string[]; + }; + emissions: MechanicEmission[]; + outcomes: MechanicOutcomeEmission[]; + finalState: PoolSnapshot[]; + finalCombat: MechanicCombatContext | null; + equipment: MechanicEquipmentContext | null; + estimatedActivationCount: number; + script: string; +}; + +export type MechanicCombatContext = { + maxHealth: number; + currentHealth: number; + maxArmor?: number; + currentArmor?: number; + maxAmmo?: number; + currentAmmo?: number; + weaponBaseDamage?: number; + values?: Record; +}; + +export type MechanicEquipmentContext = { + protocolTalentUpgrades?: number; + values?: Record; +}; + +export type ValidationResult = + | { ok: true; value: T } + | { ok: false; errors: string[] }; + +type RuntimePool = PoolSnapshot; +type EvaluationContext = { + event: MechanicEvent; + scenario: Record; + reducerValue: number; + pools: Map; + poolDefinitions: Map; + combat: MechanicCombatContext | null; + preEventCombat: MechanicCombatContext | null; + equipment: MechanicEquipmentContext | null; +}; +type ReducerState = { + count: number; + timestamps: number[]; + distinctTimestamps: Map; + previousGroup: string | null; + startedAt: number | null; + lastPeriodicIndex: number; + durationEmitted: boolean; +}; +type ReducerEmission = { at: number; value: number }; + +const AGENT_ENTITY = 'agent'; +const DEFAULT_TARGET_ENTITY = 'target'; +const EPSILON = 0.000_001; +const finiteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); +const formatNumber = (value: number) => + Number.isInteger(value) ? String(value) : String(Number(value.toFixed(3))); +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); +function getPath(source: unknown, path: string): unknown { + let current = source; + for (const segment of path.split('.').filter(Boolean)) { + if (typeof current !== 'object' || current === null) return undefined; + current = (current as Record)[segment]; + } + return current; +} + +function entityId(selector: EntitySelector | undefined, event: MechanicEvent, scope: StateScope): string | null { + if (!selector) return scope === 'agent' ? AGENT_ENTITY : event.targetId ?? null; + switch (selector.kind) { + case 'agent': + return AGENT_ENTITY; + case 'event_actor': + return event.actorId ?? AGENT_ENTITY; + case 'event_target': + return event.targetId ?? null; + case 'event_field': { + const value = getPath(event, selector.path); + return typeof value === 'string' && value.trim() ? value : null; + } + } +} + +function poolStorageKey(pool: string, entity: string) { + return `${pool}\u0000${entity}`; +} + +function stateValue( + pool: string, + selector: EntitySelector | undefined, + context: EvaluationContext +): number { + const definition = context.poolDefinitions.get(pool); + if (!definition) return 0; + const entity = entityId(selector, context.event, definition.scope); + if (!entity) return definition.initial; + return context.pools.get(poolStorageKey(pool, entity))?.value ?? definition.initial; +} + +function resolveReference(reference: ValueReference, context: EvaluationContext): unknown { + switch (reference.source) { + case 'event': + return getPath(context.event, reference.path); + case 'scenario': + return context.scenario[reference.key]; + case 'combat': { + const combat = reference.timing === 'pre_event' ? context.preEventCombat : context.combat; + return getPath(combat, reference.path); + } + case 'equipment': + return getPath(context.equipment, reference.path); + case 'reducer': + return context.reducerValue; + case 'pool': + return stateValue(reference.pool, reference.entity, context); + } +} + +function numericValue(expression: NumericExpression, context: EvaluationContext): number { + if (typeof expression === 'number') return expression; + if ('ref' in expression) { + const value = resolveReference(expression.ref, context); + return finiteNumber(value) ? value : typeof value === 'boolean' ? Number(value) : 0; + } + if (expression.op === 'clamp') { + return clamp( + numericValue(expression.value, context), + numericValue(expression.min, context), + numericValue(expression.max, context) + ); + } + const left = numericValue(expression.left, context); + const right = numericValue(expression.right, context); + switch (expression.op) { + case 'add': + return left + right; + case 'subtract': + return left - right; + case 'multiply': + return left * right; + case 'divide': + return Math.abs(right) <= EPSILON ? 0 : left / right; + case 'min': + return Math.min(left, right); + case 'max': + return Math.max(left, right); + } +} + +function operandValue(operand: ValueReference | Scalar, context: EvaluationContext): unknown { + if (typeof operand === 'object' && operand !== null && 'source' in operand) { + return resolveReference(operand, context); + } + return operand; +} + +function predicateMatches(predicate: Predicate | undefined, context: EvaluationContext): boolean { + if (!predicate || predicate.kind === 'true') return true; + if (predicate.kind === 'exists') { + const value = resolveReference(predicate.value, context); + return value !== undefined && value !== null; + } + if (predicate.kind === 'all') return predicate.predicates.every((item) => predicateMatches(item, context)); + if (predicate.kind === 'any') return predicate.predicates.some((item) => predicateMatches(item, context)); + if (predicate.kind === 'not') return !predicateMatches(predicate.predicate, context); + const left = operandValue(predicate.left, context); + const right = operandValue(predicate.right, context); + switch (predicate.operator) { + case 'eq': + return left === right; + case 'ne': + return left !== right; + case 'gt': + return typeof left === 'number' && typeof right === 'number' && left > right; + case 'gte': + return typeof left === 'number' && typeof right === 'number' && left >= right; + case 'lt': + return typeof left === 'number' && typeof right === 'number' && left < right; + case 'lte': + return typeof left === 'number' && typeof right === 'number' && left <= right; + } +} + +function scenarioValues( + program: MechanicProgram, + scenario: MechanicScenario +): { values: Record; missing: string[] } { + const supplied = scenario.mode === 'custom' ? scenario.inputs ?? {} : {}; + const values: Record = {}; + const missing: string[] = []; + for (const input of program.scenarioInputs ?? []) { + const value = scenario.mode === 'maximum' + ? input.bestCaseValue + : supplied[input.key] ?? input.defaultValue; + if (value === undefined) { + missing.push(input.key); + continue; + } + values[input.key] = value; + } + return { values, missing }; +} + +function freshReducerState(): ReducerState { + return { + count: 0, + timestamps: [], + distinctTimestamps: new Map(), + previousGroup: null, + startedAt: null, + lastPeriodicIndex: -1, + durationEmitted: false + }; +} + +function reducerGroup( + reducer: Reducer, + context: EvaluationContext +): string { + if (!('groupBy' in reducer) || !reducer.groupBy) return 'global'; + const value = resolveReference(reducer.groupBy, context); + return value === undefined || value === null ? 'missing' : String(value); +} + +function resetCount(state: ReducerState, threshold: number, reset: 'zero' | 'subtract' | 'never') { + if (reset === 'zero') state.count = 0; + else if (reset === 'subtract') state.count = Math.max(0, state.count - threshold); +} + +function reduceEvent( + reducer: Reducer, + matched: boolean, + event: MechanicEvent, + context: EvaluationContext, + state: ReducerState +): ReducerEmission[] { + if (reducer.kind === 'none') return matched ? [{ at: event.at, value: 1 }] : []; + if (reducer.kind === 'derived_scalar') { + return matched ? [{ at: event.at, value: numericValue(reducer.value, context) }] : []; + } + if (reducer.kind === 'count') { + if (!matched) return []; + state.count += 1; + if ((reducer.reset ?? 'zero') === 'never' && state.durationEmitted) return []; + if (state.count < reducer.threshold) return []; + const value = state.count; + if ((reducer.reset ?? 'zero') === 'never') state.durationEmitted = true; + resetCount(state, reducer.threshold, reducer.reset ?? 'zero'); + return [{ at: event.at, value }]; + } + if (reducer.kind === 'modulo') { + if (!matched) return []; + state.count += 1; + return state.count % reducer.every === 0 ? [{ at: event.at, value: state.count }] : []; + } + if (reducer.kind === 'distinct_count') { + if (!matched) return []; + const value = resolveReference(reducer.distinctBy, context); + if (value === undefined || value === null) return []; + for (const [key, at] of state.distinctTimestamps) { + if (event.at - at > reducer.windowSeconds + EPSILON) state.distinctTimestamps.delete(key); + } + state.distinctTimestamps.set(String(value), event.at); + if (state.distinctTimestamps.size < reducer.threshold) return []; + const count = state.distinctTimestamps.size; + state.distinctTimestamps.clear(); + return [{ at: event.at, value: count }]; + } + if (reducer.kind === 'consecutive_count') { + if (!matched) { + state.count = 0; + state.previousGroup = null; + return []; + } + const value = reducer.groupBy ? resolveReference(reducer.groupBy, context) : 'global'; + if (value === undefined || value === null) { + state.count = 0; + state.previousGroup = null; + return []; + } + const group = String(value); + state.count = state.previousGroup === group ? state.count + 1 : 1; + state.previousGroup = group; + if (state.count < reducer.threshold) return []; + const count = state.count; + state.count = 0; + return [{ at: event.at, value: count }]; + } + if (reducer.kind === 'sliding_count') { + if (!matched) return []; + state.timestamps = state.timestamps.filter( + (at) => event.at - at <= reducer.windowSeconds + EPSILON + ); + state.timestamps.push(event.at); + state.count = state.timestamps.length; + if ((reducer.reset ?? 'zero') === 'never' && state.durationEmitted) return []; + if (state.count < reducer.threshold) return []; + const value = state.count; + const reset = reducer.reset ?? 'zero'; + if (reset === 'never') state.durationEmitted = true; + if (reset === 'zero') state.timestamps = []; + else if (reset === 'subtract') state.timestamps.splice(0, reducer.threshold); + state.count = state.timestamps.length; + return [{ at: event.at, value }]; + } + if (reducer.kind === 'duration') { + if (!matched) { + state.startedAt = null; + state.durationEmitted = false; + return []; + } + if (state.startedAt === null) state.startedAt = event.at; + const elapsed = event.at - state.startedAt; + if (elapsed + EPSILON < reducer.seconds) return []; + if (!reducer.repeat && state.durationEmitted) return []; + const index = Math.floor((elapsed + EPSILON) / reducer.seconds); + if (reducer.repeat && index <= state.lastPeriodicIndex) return []; + state.durationEmitted = true; + state.lastPeriodicIndex = index; + return [{ at: event.at, value: elapsed }]; + } + if (!matched) { + state.startedAt = null; + state.lastPeriodicIndex = -1; + return []; + } + if (state.startedAt === null) { + state.startedAt = event.at; + state.lastPeriodicIndex = reducer.immediate ? 0 : -1; + return reducer.immediate ? [{ at: event.at, value: 1 }] : []; + } + const intervalSeconds = numericValue(reducer.intervalSeconds, context); + if (!finiteNumber(intervalSeconds) || intervalSeconds <= 0) return []; + const elapsed = event.at - state.startedAt; + const index = Math.floor((elapsed + EPSILON) / intervalSeconds); + const firstIndex = Math.max(state.lastPeriodicIndex + 1, 1); + const emissions: ReducerEmission[] = []; + for (let current = firstIndex; current <= index; current += 1) { + emissions.push({ + at: state.startedAt + current * intervalSeconds, + value: current + }); + } + state.lastPeriodicIndex = Math.max(state.lastPeriodicIndex, index); + return emissions; +} + +function setPoolValue( + definition: PoolDefinition, + entity: string, + value: number, + at: number, + pools: Map +): { before: number; after: number } { + const key = poolStorageKey(definition.key, entity); + const current = pools.get(key); + const before = current?.value ?? definition.initial; + const after = clamp( + Number.isFinite(value) ? value : before, + definition.min ?? Number.NEGATIVE_INFINITY, + definition.max ?? Number.POSITIVE_INFINITY + ); + pools.set(key, { + pool: definition.key, + scope: definition.scope, + entityId: entity, + value: after, + expiresAt: definition.expiresAfterSeconds === undefined + ? null + : at + definition.expiresAfterSeconds + }); + return { before, after }; +} + +function applyMutation( + mutation: ClauseMutation, + at: number, + context: EvaluationContext, + weight = 1 +): MutationRecord { + if (mutation.kind === 'none') return { kind: 'none' }; + const definition = context.poolDefinitions.get(mutation.pool); + if (!definition) return { kind: mutation.kind, pool: mutation.pool }; + const mutationWeight = clamp(weight, 0, 1); + if (mutation.kind === 'transfer') { + const from = entityId(mutation.from, context.event, definition.scope); + const to = entityId(mutation.to, context.event, definition.scope); + if (!from || !to) return { kind: 'transfer', pool: mutation.pool }; + const sourceBefore = stateValue(mutation.pool, mutation.from, context); + const targetBefore = stateValue(mutation.pool, mutation.to, context); + const requested = mutation.value === undefined || mutation.value === 'all' + ? sourceBefore + : numericValue(mutation.value, context); + const amount = clamp(requested, 0, sourceBefore) * mutationWeight; + const sourceAfter = mutation.clearSource === false ? sourceBefore : sourceBefore - amount; + const targetAfter = targetBefore + amount; + if (mutationWeight > 0) { + setPoolValue(definition, from, sourceAfter, at, context.pools); + setPoolValue(definition, to, targetAfter, at, context.pools); + } + return { + kind: 'transfer', + pool: mutation.pool, + entityId: from, + before: sourceBefore, + after: mutationWeight > 0 ? stateValue(mutation.pool, mutation.from, context) : sourceBefore, + targetEntityId: to, + targetBefore, + targetAfter: mutationWeight > 0 + ? stateValue(mutation.pool, mutation.to, context) + : targetBefore + }; + } + const entity = entityId(mutation.entity, context.event, definition.scope); + if (!entity) return { kind: mutation.kind, pool: mutation.pool }; + const before = stateValue(mutation.pool, mutation.entity, context); + let requested = before; + if (mutation.kind === 'clear') requested = definition.initial; + else { + const value = numericValue(mutation.value, context); + if (mutation.kind === 'add') requested = before + value; + else if (mutation.kind === 'consume') requested = before - value; + else requested = value; + } + const weightedRequest = before + (requested - before) * mutationWeight; + const changed = mutationWeight > 0 + ? setPoolValue(definition, entity, weightedRequest, at, context.pools) + : { before, after: before }; + return { + kind: mutation.kind, + pool: mutation.pool, + entityId: entity, + before: changed.before, + after: changed.after + }; +} + +function outcomeState(outcome: MechanicOutcome, context: EvaluationContext): number { + return outcome.statePool ? stateValue(outcome.statePool, undefined, context) : 1; +} + +function phaseMatches( + phase: OutcomePhase, + stateBefore: number, + stateAfter: number +) { + if (phase === 'on_event' || phase === 'after_event') return true; + if (phase === 'on_enter') return stateBefore <= 0 && stateAfter > 0; + if (phase === 'on_exit') return stateBefore > 0 && stateAfter <= 0; + return stateAfter > 0; +} + +function copyCombat(combat: MechanicCombatContext | null): MechanicCombatContext | null { + return combat ? { + ...combat, + values: combat.values ? { ...combat.values } : undefined + } : null; +} + +function resolveOutcomeValue( + outcome: MechanicOutcome, + value: number, + context: EvaluationContext +): number { + if (outcome.basis === 'flat') return value; + const combat = outcome.combatTiming === 'pre_event' + ? context.preEventCombat + : context.combat; + if (!combat) return 0; + if (outcome.basis === 'max_health_percent') return combat.maxHealth * value / 100; + if (outcome.basis === 'current_health_percent') return combat.currentHealth * value / 100; + return (combat.weaponBaseDamage ?? 0) * value / 100; +} + +function outcomeResource(outcome: MechanicOutcome): MechanicResource { + return outcome.resource ?? 'health'; +} + +function combatResource( + combat: MechanicCombatContext, + resource: MechanicResource +): { current: number; maximum: number; setCurrent: (value: number) => void } | null { + if (resource === 'health') { + return { + current: combat.currentHealth, + maximum: combat.maxHealth, + setCurrent: (value) => { combat.currentHealth = value; } + }; + } + if (resource === 'armor' && finiteNumber(combat.currentArmor) && finiteNumber(combat.maxArmor)) { + return { + current: combat.currentArmor, + maximum: combat.maxArmor, + setCurrent: (value) => { combat.currentArmor = value; } + }; + } + if (resource === 'ammo' && finiteNumber(combat.currentAmmo) && finiteNumber(combat.maxAmmo)) { + return { + current: combat.currentAmmo, + maximum: combat.maxAmmo, + setCurrent: (value) => { combat.currentAmmo = value; } + }; + } + return null; +} + +function emitOutcomes( + clause: MechanicClause, + phase: 'before' | 'after', + at: number, + weight: number, + before: Map, + context: EvaluationContext +): MechanicOutcomeEmission[] { + const emissions: MechanicOutcomeEmission[] = []; + const eligible = (clause.outcomes ?? []).filter((outcome) => { + if (phase === 'before' && outcome.phase !== 'on_event') return false; + if (phase === 'after' && outcome.phase === 'on_event') return false; + const current = outcomeState(outcome, context); + const previous = outcome.statePool ? before.get(outcome.statePool) ?? current : current; + if (!phaseMatches(outcome.phase, previous, current)) return false; + if (outcome.minStacks !== undefined && current < outcome.minStacks) return false; + if (outcome.maxStacks !== undefined && current > outcome.maxStacks) return false; + return true; + }).sort((left, right) => { + const priority = (outcome: MechanicOutcome) => + outcome.kind === 'resource_cost' ? 0 : outcome.kind === 'restoration' ? 2 : 1; + return priority(left) - priority(right); + }); + for (const outcome of eligible) { + const current = outcomeState(outcome, context); + const baseValue = numericValue(outcome.value, context); + const value = outcome.perStack ? baseValue * current : baseValue; + const resolvedValue = resolveOutcomeValue(outcome, value, context); + const combatBefore = copyCombat(context.combat); + let appliedValue = resolvedValue * weight; + const resource = outcome.kind === 'restoration' || outcome.kind === 'resource_cost' + ? outcomeResource(outcome) + : null; + const targetResource = context.combat && resource ? combatResource(context.combat, resource) : null; + if (outcome.kind === 'resource_cost' && targetResource) { + appliedValue = Math.min(targetResource.current, Math.max(0, appliedValue)); + targetResource.setCurrent(targetResource.current - appliedValue); + } else if (outcome.kind === 'restoration' && targetResource) { + appliedValue = Math.min( + targetResource.maximum - targetResource.current, + Math.max(0, appliedValue) + ); + targetResource.setCurrent(targetResource.current + appliedValue); + } else if ((outcome.kind === 'resource_cost' || outcome.kind === 'restoration') && !targetResource) { + appliedValue = 0; + } + emissions.push({ + clauseId: clause.id, + outcomeId: outcome.id, + at, + phase: outcome.phase, + kind: outcome.kind, + scope: outcome.scope, + resource, + attributeId: outcome.attributeId ?? null, + basis: outcome.basis, + combatTiming: outcome.combatTiming ?? 'current', + value, + resolvedValue, + appliedValue, + weight, + stateValue: current, + combatBefore, + combatAfter: copyCombat(context.combat) + }); + } + return emissions; +} + +function expirePools( + at: number, + clauses: MechanicClause[], + scenario: Record, + pools: Map, + definitions: Map, + combat: MechanicCombatContext | null, + equipment: MechanicEquipmentContext | null +): MechanicOutcomeEmission[] { + const expired = [...pools.entries()] + .filter(([, state]) => state.expiresAt !== null && state.expiresAt <= at + EPSILON) + .sort((left, right) => + (left[1].expiresAt ?? 0) - (right[1].expiresAt ?? 0) + || left[0].localeCompare(right[0])); + const emissions: MechanicOutcomeEmission[] = []; + let index = 0; + while (index < expired.length) { + const expiryAt = expired[index][1].expiresAt as number; + const group: typeof expired = []; + while ( + index < expired.length + && Math.abs((expired[index][1].expiresAt as number) - expiryAt) <= EPSILON + ) { + group.push(expired[index]); + index += 1; + } + const preEventCombat = copyCombat(combat); + for (const [key, state] of group) { + const definition = definitions.get(state.pool); + if (!definition) continue; + pools.set(key, { ...state, value: definition.initial, expiresAt: null }); + } + for (const [, state] of group) { + const definition = definitions.get(state.pool); + if (!definition) continue; + const event: MechanicEvent = { + kind: 'tick', + at: expiryAt, + actorId: AGENT_ENTITY, + targetId: definition.scope === 'target' ? state.entityId : DEFAULT_TARGET_ENTITY + }; + const context: EvaluationContext = { + event, + scenario, + reducerValue: 0, + pools, + poolDefinitions: definitions, + combat, + preEventCombat, + equipment + }; + const before = new Map([[state.pool, state.value]]); + for (const clause of clauses) { + const exitOutcomes = (clause.outcomes ?? []).filter((outcome) => + outcome.phase === 'on_exit' && outcome.statePool === state.pool); + if (!exitOutcomes.length) continue; + emissions.push(...emitOutcomes( + { ...clause, outcomes: exitOutcomes }, + 'after', + expiryAt, + 1, + before, + context + )); + } + } + } + return emissions; +} + +function initialPools( + definitions: Map, + mode: CalculationMode +): Map { + const pools = new Map(); + for (const definition of definitions.values()) { + if (mode === 'custom' && definition.scope === 'target') continue; + const entity = definition.scope === 'agent' ? AGENT_ENTITY : DEFAULT_TARGET_ENTITY; + const value = mode === 'maximum' + ? definition.bestCaseValue ?? definition.max ?? definition.initial + : definition.initial; + pools.set(poolStorageKey(definition.key, entity), { + pool: definition.key, + scope: definition.scope, + entityId: entity, + value, + expiresAt: null + }); + } + return pools; +} + +function invalidEvaluation( + program: MechanicProgram, + scenario: MechanicScenario, + errors: string[] +): MechanicEvaluation { + return { + status: 'INVALID', + errors, + programId: program.id, + mode: scenario.mode, + scenarioInputs: {}, + readiness: { status: 'REQUIRES_ASSUMPTIONS', missingInputs: [] }, + emissions: [], + outcomes: [], + finalState: [], + finalCombat: null, + equipment: null, + estimatedActivationCount: 0, + script: formatMechanicProgram(program) + }; +} + +function validateReference(reference: ValueReference, pools: Set, inputs: Set, errors: string[], path: string) { + if (reference.source === 'pool' && !pools.has(reference.pool)) { + errors.push(`${path} references unknown pool "${reference.pool}".`); + } + if (reference.source === 'scenario' && !inputs.has(reference.key)) { + errors.push(`${path} references unknown scenario input "${reference.key}".`); + } + if (reference.source === 'event' && !reference.path.trim()) errors.push(`${path} requires an event path.`); + if (reference.source === 'combat' && !reference.path.trim()) errors.push(`${path} requires a combat path.`); + if (reference.source === 'equipment' && !reference.path.trim()) errors.push(`${path} requires an equipment path.`); +} + +function visitExpression( + expression: NumericExpression, + pools: Set, + inputs: Set, + errors: string[], + path: string +) { + if (typeof expression === 'number') { + if (!finiteNumber(expression)) errors.push(`${path} must be finite.`); + return; + } + if ('ref' in expression) { + validateReference(expression.ref, pools, inputs, errors, path); + return; + } + if (expression.op === 'clamp') { + visitExpression(expression.value, pools, inputs, errors, `${path} value`); + visitExpression(expression.min, pools, inputs, errors, `${path} minimum`); + visitExpression(expression.max, pools, inputs, errors, `${path} maximum`); + return; + } + visitExpression(expression.left, pools, inputs, errors, `${path} left operand`); + visitExpression(expression.right, pools, inputs, errors, `${path} right operand`); +} + +function visitPredicate( + predicate: Predicate, + pools: Set, + inputs: Set, + errors: string[], + path: string +) { + if (predicate.kind === 'true') return; + if (predicate.kind === 'exists') { + validateReference(predicate.value, pools, inputs, errors, path); + return; + } + if (predicate.kind === 'not') { + visitPredicate(predicate.predicate, pools, inputs, errors, `${path} NOT`); + return; + } + if (predicate.kind === 'all' || predicate.kind === 'any') { + if (!predicate.predicates.length) errors.push(`${path} ${predicate.kind.toUpperCase()} requires a predicate.`); + predicate.predicates.forEach((item, index) => + visitPredicate(item, pools, inputs, errors, `${path} ${index + 1}`)); + return; + } + if (typeof predicate.left === 'object' && predicate.left !== null) { + validateReference(predicate.left, pools, inputs, errors, `${path} left operand`); + } + if (typeof predicate.right === 'object' && predicate.right !== null) { + validateReference(predicate.right, pools, inputs, errors, `${path} right operand`); + } +} + +function expressionUsesCombat(expression: NumericExpression): boolean { + if (typeof expression === 'number') return false; + if ('ref' in expression) return expression.ref.source === 'combat'; + if (expression.op === 'clamp') { + return expressionUsesCombat(expression.value) + || expressionUsesCombat(expression.min) + || expressionUsesCombat(expression.max); + } + return expressionUsesCombat(expression.left) || expressionUsesCombat(expression.right); +} + +function expressionUsesEquipment(expression: NumericExpression): boolean { + if (typeof expression === 'number') return false; + if ('ref' in expression) return expression.ref.source === 'equipment'; + if (expression.op === 'clamp') { + return expressionUsesEquipment(expression.value) + || expressionUsesEquipment(expression.min) + || expressionUsesEquipment(expression.max); + } + return expressionUsesEquipment(expression.left) || expressionUsesEquipment(expression.right); +} + +function predicateUsesCombat(predicate: Predicate): boolean { + if (predicate.kind === 'true') return false; + if (predicate.kind === 'exists') return predicate.value.source === 'combat'; + if (predicate.kind === 'not') return predicateUsesCombat(predicate.predicate); + if (predicate.kind === 'all' || predicate.kind === 'any') { + return predicate.predicates.some(predicateUsesCombat); + } + return ( + (typeof predicate.left === 'object' && predicate.left?.source === 'combat') + || (typeof predicate.right === 'object' && predicate.right?.source === 'combat') + ); +} + +function predicateUsesEquipment(predicate: Predicate): boolean { + if (predicate.kind === 'true') return false; + if (predicate.kind === 'exists') return predicate.value.source === 'equipment'; + if (predicate.kind === 'not') return predicateUsesEquipment(predicate.predicate); + if (predicate.kind === 'all' || predicate.kind === 'any') { + return predicate.predicates.some(predicateUsesEquipment); + } + return ( + (typeof predicate.left === 'object' && predicate.left?.source === 'equipment') + || (typeof predicate.right === 'object' && predicate.right?.source === 'equipment') + ); +} + +function programCombatRequirements(program: MechanicProgram) { + let required = false; + let weaponBaseDamage = false; + let equipment = false; + for (const clause of program.clauses) { + if (clause.if && predicateUsesCombat(clause.if)) required = true; + if (clause.if && predicateUsesEquipment(clause.if)) equipment = true; + if (clause.weight && expressionUsesCombat(clause.weight)) required = true; + if (clause.weight && expressionUsesEquipment(clause.weight)) equipment = true; + if (clause.reducer.kind === 'derived_scalar' && expressionUsesCombat(clause.reducer.value)) required = true; + if (clause.reducer.kind === 'derived_scalar' && expressionUsesEquipment(clause.reducer.value)) equipment = true; + for (const mutation of clause.mutations) { + if ('value' in mutation && mutation.value !== undefined && mutation.value !== 'all') { + if (expressionUsesCombat(mutation.value)) required = true; + if (expressionUsesEquipment(mutation.value)) equipment = true; + } + } + for (const outcome of clause.outcomes ?? []) { + if (outcome.basis !== 'flat' || expressionUsesCombat(outcome.value)) required = true; + if (expressionUsesEquipment(outcome.value)) equipment = true; + if (outcome.basis === 'weapon_base_percent') weaponBaseDamage = true; + } + } + return { required, weaponBaseDamage, equipment }; +} + +export function validateMechanicProgram(program: MechanicProgram): ValidationResult { + const errors: string[] = []; + if (!program.id.trim()) errors.push('Program ID is required.'); + if (!program.name.trim()) errors.push('Program name is required.'); + const inputKeys = new Set(); + for (const [index, input] of (program.scenarioInputs ?? []).entries()) { + const path = `Scenario input ${index + 1}`; + if (!input.key.trim()) errors.push(`${path} requires a key.`); + if (inputKeys.has(input.key)) errors.push(`Scenario input "${input.key}" is duplicated.`); + inputKeys.add(input.key); + const required = (input.readiness ?? 'ready') === 'ready'; + if (input.kind === 'toggle') { + if ( + (required && (typeof input.defaultValue !== 'boolean' || typeof input.bestCaseValue !== 'boolean')) + || (input.defaultValue !== undefined && typeof input.defaultValue !== 'boolean') + || (input.bestCaseValue !== undefined && typeof input.bestCaseValue !== 'boolean') + ) { + errors.push(`${path} toggle values must be booleans.`); + } + } else { + if ( + (required && (!finiteNumber(input.defaultValue) || !finiteNumber(input.bestCaseValue))) + || (input.defaultValue !== undefined && !finiteNumber(input.defaultValue)) + || (input.bestCaseValue !== undefined && !finiteNumber(input.bestCaseValue)) + ) { + errors.push(`${path} values must be finite numbers.`); + } + if (input.min !== undefined && input.max !== undefined && input.min > input.max) { + errors.push(`${path} minimum cannot exceed its maximum.`); + } + for (const value of [input.defaultValue, input.bestCaseValue]) { + if (typeof value !== 'number') continue; + if (input.min !== undefined && value < input.min) errors.push(`${path} value is below its minimum.`); + if (input.max !== undefined && value > input.max) errors.push(`${path} value exceeds its maximum.`); + } + } + } + const poolKeys = new Set(); + for (const [index, pool] of (program.pools ?? []).entries()) { + const path = `Pool ${index + 1}`; + if (!pool.key.trim()) errors.push(`${path} requires a key.`); + if (poolKeys.has(pool.key)) errors.push(`Pool "${pool.key}" is duplicated.`); + poolKeys.add(pool.key); + if (!finiteNumber(pool.initial)) errors.push(`${path} initial value must be finite.`); + if (pool.min !== undefined && pool.max !== undefined && pool.min > pool.max) { + errors.push(`${path} minimum cannot exceed its maximum.`); + } + if (pool.min !== undefined && pool.initial < pool.min) errors.push(`${path} initial value is below its minimum.`); + if (pool.max !== undefined && pool.initial > pool.max) errors.push(`${path} initial value exceeds its maximum.`); + if (pool.bestCaseValue !== undefined) { + if (!finiteNumber(pool.bestCaseValue)) errors.push(`${path} best-case value must be finite.`); + if (pool.min !== undefined && pool.bestCaseValue < pool.min) errors.push(`${path} best-case value is below its minimum.`); + if (pool.max !== undefined && pool.bestCaseValue > pool.max) errors.push(`${path} best-case value exceeds its maximum.`); + } + if (pool.expiresAfterSeconds !== undefined && (!finiteNumber(pool.expiresAfterSeconds) || pool.expiresAfterSeconds <= 0)) { + errors.push(`${path} expiry must be greater than 0 seconds.`); + } + } + const clauseIds = new Set(); + const outcomeIds = new Set(); + for (const [index, clause] of program.clauses.entries()) { + const path = `Clause ${index + 1}`; + if (!clause.id.trim()) errors.push(`${path} requires an ID.`); + if (clause.recipeKey !== undefined && !clause.recipeKey.trim()) { + errors.push(`${path} recipe key cannot be empty.`); + } + if (clauseIds.has(clause.id)) errors.push(`Clause "${clause.id}" is duplicated.`); + clauseIds.add(clause.id); + if (!(MECHANIC_EVENT_KINDS as readonly string[]).includes(clause.when)) { + errors.push(`${path} has an unknown event kind.`); + } + if (clause.if) visitPredicate(clause.if, poolKeys, inputKeys, errors, `${path} predicate`); + if (clause.weight) visitExpression(clause.weight, poolKeys, inputKeys, errors, `${path} weight`); + if ( + clause.cooldownSeconds !== undefined + && (!finiteNumber(clause.cooldownSeconds) || clause.cooldownSeconds <= 0) + ) { + errors.push(`${path} cooldown must be greater than 0 seconds.`); + } + const reducer = clause.reducer; + if (reducer.kind === 'count' && (!Number.isInteger(reducer.threshold) || reducer.threshold <= 0)) { + errors.push(`${path} count threshold must be a positive integer.`); + } + if (reducer.kind === 'modulo' && (!Number.isInteger(reducer.every) || reducer.every <= 0)) { + errors.push(`${path} modulo interval must be a positive integer.`); + } + if (reducer.kind === 'distinct_count') { + if (!Number.isInteger(reducer.threshold) || reducer.threshold <= 0) { + errors.push(`${path} distinct-count threshold must be a positive integer.`); + } + if (!finiteNumber(reducer.windowSeconds) || reducer.windowSeconds <= 0) { + errors.push(`${path} distinct-count window must be greater than 0 seconds.`); + } + validateReference(reducer.distinctBy, poolKeys, inputKeys, errors, `${path} distinct-count value`); + } + if (reducer.kind === 'consecutive_count' && (!Number.isInteger(reducer.threshold) || reducer.threshold <= 0)) { + errors.push(`${path} consecutive-count threshold must be a positive integer.`); + } + if (reducer.kind === 'sliding_count') { + if (!Number.isInteger(reducer.threshold) || reducer.threshold <= 0) { + errors.push(`${path} sliding-count threshold must be a positive integer.`); + } + if (!finiteNumber(reducer.windowSeconds) || reducer.windowSeconds <= 0) { + errors.push(`${path} sliding window must be greater than 0 seconds.`); + } + } + if (reducer.kind === 'duration' && (!finiteNumber(reducer.seconds) || reducer.seconds <= 0)) { + errors.push(`${path} duration must be greater than 0 seconds.`); + } + if (reducer.kind === 'periodic') { + visitExpression( + reducer.intervalSeconds, + poolKeys, + inputKeys, + errors, + `${path} periodic interval` + ); + if ( + finiteNumber(reducer.intervalSeconds) + && reducer.intervalSeconds <= 0 + ) { + errors.push(`${path} periodic interval must be greater than 0 seconds.`); + } + } + if ('groupBy' in reducer && reducer.groupBy) { + validateReference(reducer.groupBy, poolKeys, inputKeys, errors, `${path} reducer group`); + } + if (reducer.kind === 'derived_scalar') { + visitExpression(reducer.value, poolKeys, inputKeys, errors, `${path} reducer value`); + } + for (const [mutationIndex, mutation] of clause.mutations.entries()) { + if (mutation.kind === 'none') continue; + const mutationPath = `${path} mutation ${mutationIndex + 1}`; + if (!poolKeys.has(mutation.pool)) errors.push(`${mutationPath} references unknown pool "${mutation.pool}".`); + if ('value' in mutation && mutation.value !== undefined && mutation.value !== 'all') { + visitExpression(mutation.value, poolKeys, inputKeys, errors, `${mutationPath} value`); + } + if (mutation.kind === 'transfer') { + const definition = (program.pools ?? []).find((pool) => pool.key === mutation.pool); + if (definition && definition.scope !== 'target') { + errors.push(`${mutationPath} can only transfer a target-scoped pool.`); + } + } + } + for (const [outcomeIndex, outcome] of (clause.outcomes ?? []).entries()) { + const outcomePath = `${path} outcome ${outcomeIndex + 1}`; + if (!outcome.id.trim()) errors.push(`${outcomePath} requires an ID.`); + if (outcomeIds.has(outcome.id)) errors.push(`Outcome "${outcome.id}" is duplicated.`); + outcomeIds.add(outcome.id); + if (outcome.kind === 'attribute_modifier' && !outcome.attributeId?.trim()) { + errors.push(`${outcomePath} requires an Attribute ID.`); + } + if (![ + 'self', + 'equipped_weapon', + 'target', + 'allies', + 'self_and_allies', + 'assigned_ability', + 'all_skills' + ].includes(outcome.scope)) { + errors.push(`${outcomePath} has an unsupported scope.`); + } + if ((outcome.kind === 'restoration' || outcome.kind === 'resource_cost') && outcome.resource === undefined) { + errors.push(`${outcomePath} requires a resource.`); + } + if ((outcome.kind === 'restoration' || outcome.kind === 'resource_cost') && outcome.resource !== undefined + && !['health', 'armor', 'ammo'].includes(outcome.resource)) { + errors.push(`${outcomePath} has an unsupported resource.`); + } + if (outcome.resource !== undefined && outcome.kind !== 'restoration' && outcome.kind !== 'resource_cost') { + errors.push(`${outcomePath} can only set a resource for restoration or resource-cost outcomes.`); + } + const allowedBasis: Record = { + attribute_modifier: ['flat'], + direct_damage: ['flat', 'max_health_percent', 'current_health_percent', 'weapon_base_percent'], + restoration: ['flat', 'max_health_percent', 'current_health_percent'], + resource_cost: ['flat', 'max_health_percent', 'current_health_percent'], + marker: ['flat'] + }; + if (!allowedBasis[outcome.kind].includes(outcome.basis)) { + errors.push(`${outcomePath} cannot use basis "${outcome.basis}".`); + } + if (outcome.statePool && !poolKeys.has(outcome.statePool)) { + errors.push(`${outcomePath} references unknown pool "${outcome.statePool}".`); + } + if ( + outcome.minStacks !== undefined + && outcome.maxStacks !== undefined + && outcome.minStacks > outcome.maxStacks + ) { + errors.push(`${outcomePath} minimum stacks cannot exceed maximum stacks.`); + } + visitExpression(outcome.value, poolKeys, inputKeys, errors, `${outcomePath} value`); + } + } + return errors.length ? { ok: false, errors } : { ok: true, value: program }; +} + +export function evaluateMechanicProgram( + program: MechanicProgram, + events: MechanicEvent[], + scenario: MechanicScenario, + combatContext?: MechanicCombatContext, + equipmentContext?: MechanicEquipmentContext +): MechanicEvaluation { + const validation = validateMechanicProgram(program); + const scenarioErrors: string[] = []; + if (!finiteNumber(scenario.encounterSeconds) || scenario.encounterSeconds <= 0) { + scenarioErrors.push('Encounter seconds must be greater than 0.'); + } + if (scenario.mode === 'custom') { + const definitions = new Map((program.scenarioInputs ?? []).map((input) => [input.key, input])); + for (const [key, value] of Object.entries(scenario.inputs ?? {})) { + const definition = definitions.get(key); + if (!definition) { + scenarioErrors.push(`Custom scenario contains unknown input "${key}".`); + continue; + } + if (definition.kind === 'toggle') { + if (typeof value !== 'boolean') scenarioErrors.push(`Scenario input "${key}" must be a boolean.`); + continue; + } + if (!finiteNumber(value)) { + scenarioErrors.push(`Scenario input "${key}" must be a finite number.`); + continue; + } + if (definition.min !== undefined && value < definition.min) { + scenarioErrors.push(`Scenario input "${key}" is below its minimum.`); + } + if (definition.max !== undefined && value > definition.max) { + scenarioErrors.push(`Scenario input "${key}" exceeds its maximum.`); + } + } + } + const combatRequirements = programCombatRequirements(program); + if (combatRequirements.required && !combatContext) { + scenarioErrors.push('This mechanic requires authoritative combat context.'); + } + if (combatContext) { + if (!finiteNumber(combatContext.maxHealth) || combatContext.maxHealth <= 0) { + scenarioErrors.push('Combat maximum Health must be greater than 0.'); + } + if ( + !finiteNumber(combatContext.currentHealth) + || combatContext.currentHealth < 0 + || combatContext.currentHealth > combatContext.maxHealth + ) { + scenarioErrors.push('Combat current Health must be between 0 and maximum Health.'); + } + if ( + combatContext.weaponBaseDamage !== undefined + && (!finiteNumber(combatContext.weaponBaseDamage) || combatContext.weaponBaseDamage < 0) + ) { + scenarioErrors.push('Combat weapon base damage must be 0 or greater.'); + } + if (combatRequirements.weaponBaseDamage && combatContext.weaponBaseDamage === undefined) { + scenarioErrors.push('This mechanic requires authoritative weapon base damage.'); + } + } + if (combatRequirements.equipment && !equipmentContext) { + scenarioErrors.push('This mechanic requires authoritative equipment context.'); + } + if ( + equipmentContext?.protocolTalentUpgrades !== undefined + && ( + !Number.isInteger(equipmentContext.protocolTalentUpgrades) + || equipmentContext.protocolTalentUpgrades < 0 + || equipmentContext.protocolTalentUpgrades > 5 + ) + ) { + scenarioErrors.push('Protocol talent upgrades must be an integer from 0 to 5.'); + } + if (!validation.ok || scenarioErrors.length) { + return invalidEvaluation(program, scenario, [ + ...(validation.ok ? [] : validation.errors), + ...scenarioErrors + ]); + } + const resolvedScenario = scenarioValues(program, scenario); + const inputs = resolvedScenario.values; + const combat = copyCombat(combatContext ?? null); + const equipment = equipmentContext ? { + ...equipmentContext, + values: equipmentContext.values ? { ...equipmentContext.values } : undefined + } : null; + const poolDefinitions = new Map((program.pools ?? []).map((pool) => [pool.key, pool])); + const pools = initialPools(poolDefinitions, scenario.mode); + const reducerStates = new Map(); + const cooldowns = new Map(); + const emissions: MechanicEmission[] = []; + const outcomes: MechanicOutcomeEmission[] = []; + if (scenario.mode === 'maximum') { + for (const clause of program.clauses) { + const event: MechanicEvent = { + kind: clause.when, + at: 0, + actorId: AGENT_ENTITY, + targetId: DEFAULT_TARGET_ENTITY + }; + const context: EvaluationContext = { + event, + scenario: inputs, + reducerValue: 1, + pools, + poolDefinitions, + combat, + preEventCombat: copyCombat(combat), + equipment + }; + const before = new Map((clause.outcomes ?? []) + .map((outcome) => outcome.statePool) + .filter((pool): pool is string => Boolean(pool)) + .map((pool) => [pool, stateValue(pool, undefined, context)])); + outcomes.push(...emitOutcomes({ + ...clause, + outcomes: (clause.outcomes ?? []).filter((outcome) => outcome.phase === 'while_active') + }, 'after', 0, 1, before, context)); + } + } + const ordered = events + .map((event, index) => ({ event, index })) + .filter(({ event }) => finiteNumber(event.at) && event.at >= 0 && event.at <= scenario.encounterSeconds) + .sort((left, right) => left.event.at - right.event.at || left.index - right.index) + .map(({ event }) => event); + + for (const event of ordered) { + outcomes.push(...expirePools( + event.at, + program.clauses, + inputs, + pools, + poolDefinitions, + combat, + equipment + )); + const preEventCombat = copyCombat(combat); + for (const clause of program.clauses) { + if (clause.when !== event.kind) continue; + const baseContext: EvaluationContext = { + event, + scenario: inputs, + reducerValue: 0, + pools, + poolDefinitions, + combat, + preEventCombat, + equipment + }; + const matched = predicateMatches(clause.if, baseContext); + const group = reducerGroup(clause.reducer, baseContext); + const stateKey = `${clause.id}\u0000${group}`; + if (event.at + EPSILON < (cooldowns.get(stateKey) ?? 0)) continue; + const reducerState = reducerStates.get(stateKey) ?? freshReducerState(); + reducerStates.set(stateKey, reducerState); + const reduced = reduceEvent(clause.reducer, matched, event, baseContext, reducerState); + for (const reduction of reduced) { + if (reduction.at + EPSILON < (cooldowns.get(stateKey) ?? 0)) continue; + const context = { ...baseContext, reducerValue: reduction.value }; + const weight = clause.weight ? clamp(numericValue(clause.weight, context), 0, 1) : 1; + const relevantPools = new Set((clause.outcomes ?? []) + .map((outcome) => outcome.statePool) + .filter((pool): pool is string => Boolean(pool))); + const before = new Map([...relevantPools].map((pool) => [pool, stateValue(pool, undefined, context)])); + outcomes.push(...emitOutcomes(clause, 'before', reduction.at, weight, before, context)); + const mutationRecords = clause.mutations.map((mutation) => + applyMutation(mutation, reduction.at, context, weight)); + emissions.push({ + clauseId: clause.id, + at: reduction.at, + weight, + reducerValue: reduction.value, + mutations: mutationRecords + }); + if (clause.cooldownSeconds) { + cooldowns.set(stateKey, reduction.at + clause.cooldownSeconds); + } + outcomes.push(...emitOutcomes(clause, 'after', reduction.at, weight, before, context)); + } + } + } + outcomes.push(...expirePools( + scenario.encounterSeconds, + program.clauses, + inputs, + pools, + poolDefinitions, + combat, + equipment + )); + return { + status: 'OK', + errors: [], + programId: program.id, + mode: scenario.mode, + scenarioInputs: inputs, + readiness: { + status: resolvedScenario.missing.length ? 'REQUIRES_ASSUMPTIONS' : 'READY', + missingInputs: resolvedScenario.missing + }, + emissions, + outcomes, + finalState: [...pools.values()].sort((left, right) => + left.pool.localeCompare(right.pool) || left.entityId.localeCompare(right.entityId)), + finalCombat: copyCombat(combat), + equipment, + estimatedActivationCount: emissions.reduce((total, emission) => total + emission.weight, 0), + script: formatMechanicProgram(program) + }; +} + +function formatEntity(selector: EntitySelector | undefined) { + if (!selector) return ''; + switch (selector.kind) { + case 'agent': + return ' FOR AGENT'; + case 'event_actor': + return ' FOR EVENT ACTOR'; + case 'event_target': + return ' FOR EVENT TARGET'; + case 'event_field': + return ` FOR EVENT.${selector.path}`; + } +} + +function formatReference(reference: ValueReference): string { + switch (reference.source) { + case 'event': + return `EVENT.${reference.path}`; + case 'scenario': + return `SCENARIO.${reference.key}`; + case 'combat': + return `${reference.timing === 'pre_event' ? 'PRE-EVENT ' : ''}COMBAT.${reference.path}`; + case 'equipment': + return `EQUIPMENT.${reference.path}`; + case 'reducer': + return 'REDUCER VALUE'; + case 'pool': + return `${reference.pool}${formatEntity(reference.entity)}`; + } +} + +function formatExpression(expression: NumericExpression): string { + if (typeof expression === 'number') return formatNumber(expression); + if ('ref' in expression) return formatReference(expression.ref); + if (expression.op === 'clamp') { + return `CLAMP(${formatExpression(expression.value)}, ${formatExpression(expression.min)}, ${formatExpression(expression.max)})`; + } + const operators = { + add: '+', + subtract: '-', + multiply: '*', + divide: '/', + min: 'MIN', + max: 'MAX' + } as const; + if (expression.op === 'min' || expression.op === 'max') { + return `${operators[expression.op]}(${formatExpression(expression.left)}, ${formatExpression(expression.right)})`; + } + return `(${formatExpression(expression.left)} ${operators[expression.op]} ${formatExpression(expression.right)})`; +} + +function formatOperand(operand: ValueReference | Scalar): string { + if (typeof operand === 'object' && operand !== null) return formatReference(operand); + if (typeof operand === 'string') return JSON.stringify(operand); + return String(operand); +} + +function formatPredicate(predicate: Predicate): string { + if (predicate.kind === 'true') return 'TRUE'; + if (predicate.kind === 'exists') return `EXISTS ${formatReference(predicate.value)}`; + if (predicate.kind === 'not') return `NOT (${formatPredicate(predicate.predicate)})`; + if (predicate.kind === 'all' || predicate.kind === 'any') { + return predicate.predicates + .map((item) => `(${formatPredicate(item)})`) + .join(predicate.kind === 'all' ? ' AND ' : ' OR '); + } + const operators = { eq: '==', ne: '!=', gt: '>', gte: '>=', lt: '<', lte: '<=' } as const; + return `${formatOperand(predicate.left)} ${operators[predicate.operator]} ${formatOperand(predicate.right)}`; +} + +function formatReducer(reducer: Reducer): string[] { + const group = 'groupBy' in reducer && reducer.groupBy + ? ` PER ${formatReference(reducer.groupBy)}` + : ''; + switch (reducer.kind) { + case 'none': + return []; + case 'count': + return [`COUNT ${formatNumber(reducer.threshold)} MATCHES${group}`]; + case 'modulo': + return [`EVERY ${formatNumber(reducer.every)} MATCHES${group}`]; + case 'distinct_count': + return [ + `COUNT ${formatNumber(reducer.threshold)} DISTINCT ${formatReference(reducer.distinctBy)} WITHIN ${formatNumber(reducer.windowSeconds)}s` + ]; + case 'consecutive_count': + return [`COUNT ${formatNumber(reducer.threshold)} CONSECUTIVE MATCHES${group}`]; + case 'sliding_count': + return [ + `COUNT ${formatNumber(reducer.threshold)} MATCHES WITHIN ${formatNumber(reducer.windowSeconds)}s${group}` + ]; + case 'duration': + return [`AFTER CONDITION HOLDS FOR ${formatNumber(reducer.seconds)}s${group}`]; + case 'periodic': + return [`EVERY ${formatExpression(reducer.intervalSeconds)}s WHILE TRUE${group}`]; + case 'derived_scalar': + return [`DERIVE ${formatExpression(reducer.value)}`]; + } +} + +function formatMutation(mutation: ClauseMutation): string { + if (mutation.kind === 'none') return 'NO STATE CHANGE'; + if (mutation.kind === 'transfer') { + const value = mutation.value === undefined || mutation.value === 'all' + ? 'ALL' + : formatExpression(mutation.value); + return `TRANSFER ${value} ${mutation.pool}${formatEntity(mutation.from)} TO${formatEntity(mutation.to).replace(' FOR', '')}`; + } + if (mutation.kind === 'clear') return `CLEAR ${mutation.pool}${formatEntity(mutation.entity)}`; + const verbs = { add: 'ADD', set: 'SET', consume: 'CONSUME', derive: 'DERIVE' } as const; + const preposition = mutation.kind === 'consume' ? 'FROM' : mutation.kind === 'set' || mutation.kind === 'derive' ? 'AS' : 'TO'; + return `${verbs[mutation.kind]} ${formatExpression(mutation.value)} ${preposition} ${mutation.pool}${formatEntity(mutation.entity)}`; +} + +function formatOutcome(outcome: MechanicOutcome): string { + const stackBand = outcome.minStacks !== undefined || outcome.maxStacks !== undefined + ? ` AT STACKS ${outcome.minStacks ?? 0}..${outcome.maxStacks ?? 'MAX'}` + : ''; + const perStack = outcome.perStack ? ' PER STACK' : ''; + const attribute = outcome.attributeId ? ` ${outcome.attributeId}` : ''; + const resource = outcome.kind === 'restoration' || outcome.kind === 'resource_cost' + ? ` ${outcomeResource(outcome).toUpperCase()}` + : ''; + const timing = outcome.basis === 'current_health_percent' + ? ` USING ${(outcome.combatTiming ?? 'current') === 'pre_event' ? 'PRE-EVENT' : 'CURRENT'} HEALTH` + : ''; + return `${outcome.phase.toUpperCase()}: APPLY ${formatExpression(outcome.value)} ${outcome.basis.toUpperCase()}${resource}${attribute}${perStack}${stackBand}${timing} // ${outcome.label}`; +} + +export function formatMechanicProgram(program: MechanicProgram): string { + const lines = [`MECHANIC ${program.name.toUpperCase()}`]; + for (const pool of program.pools ?? []) { + const bounds = pool.min !== undefined || pool.max !== undefined + ? ` ${formatNumber(pool.min ?? 0)}..${pool.max === undefined ? 'UNBOUNDED' : formatNumber(pool.max)}` + : ''; + const expiry = pool.expiresAfterSeconds ? `; EXPIRES ${formatNumber(pool.expiresAfterSeconds)}s AFTER WRITE` : ''; + lines.push(`STATE ${pool.key} [${pool.scope.toUpperCase()}]${bounds}${expiry}`); + } + for (const clause of program.clauses) { + const recipe = clause.recipeKey ? ` [RECIPE ${clause.recipeKey}]` : ''; + lines.push('', `WHEN ${MECHANIC_EVENT_REGISTRY[clause.when].label.toUpperCase()} // ${clause.label ?? clause.id}${recipe}`); + if (clause.if) lines.push(`IF ${formatPredicate(clause.if)}`); + lines.push(...formatReducer(clause.reducer)); + if (clause.cooldownSeconds) lines.push(`COOLDOWN ${formatNumber(clause.cooldownSeconds)}s`); + if (clause.weight) lines.push(`WEIGHT ${formatExpression(clause.weight)}`); + lines.push(...clause.mutations.map(formatMutation)); + lines.push(...(clause.outcomes ?? []).map(formatOutcome)); + } + return lines.join('\n'); +} diff --git a/src/lib/calculations/skill.test.ts b/src/lib/calculations/skill.test.ts new file mode 100644 index 0000000..394626b --- /dev/null +++ b/src/lib/calculations/skill.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it } from 'vitest'; +import { + calculateSkillDps, + type SkillAbilityInput, + type SkillCalculationInput +} from './skill.js'; + +const directAbility = ( + overrides: Partial = {} +): SkillAbilityInput => ({ + id: 'seeker', + baseDamage: 100, + damageType: 'blast', + delivery: { type: 'direct', hits: 2 }, + castSeconds: 0, + cooldownSeconds: 5, + targetCap: 3, + chargePolicy: { type: 'none' }, + activation: { mode: 'on_cooldown' }, + ...overrides +}); + +const base = (overrides: Partial = {}): SkillCalculationInput => ({ + rules: { + revisionId: 'skill-rules-1', + bindings: [ + { attributeId: 'skillDamage', stage: 'skill_damage', operator: 'add' }, + { attributeId: 'criticalChance', stage: 'critical_chance', operator: 'add' }, + { attributeId: 'criticalDamage', stage: 'critical_damage', operator: 'add' }, + { attributeId: 'multishotChance', stage: 'multishot_chance', operator: 'add' }, + { attributeId: 'multishotDamage', stage: 'multishot_damage', operator: 'add' } + ] + }, + abilities: [directAbility()], + attributes: { + skillDamage: 20, + criticalChance: 25, + criticalDamage: 50, + multishotChance: 20, + multishotDamage: 100 + }, + assumptions: { encounterSeconds: 10, targetCount: 5 }, + ...overrides +}); + +describe('calculateSkillDps', () => { + it('calculates critical and multishot expected damage without a headshot model', () => { + const result = calculateSkillDps(base()); + expect(result.status).toBe('OK'); + if (result.status !== 'OK') return; + + const ability = result.abilities[0]; + expect(result.revisionId).toBe('skill-rules-1'); + expect(ability.activationCount).toBe(2); + expect(ability.damageEventCount).toBe(4); + expect(ability.effectiveTargets).toBe(3); + expect(ability.damage.averageExpectedDamagePerHit).toBe(162); + expect(ability.damage.singleTargetDamagePerActivation).toBe(324); + expect(ability.damage.singleTargetEncounterDamage).toBe(648); + expect(ability.damage.aggregateEncounterDamage).toBe(1944); + expect(ability.damage.singleTargetDps).toBe(64.8); + expect(ability.damage.aggregateDps).toBe(194.4); + }); + + it('reads current modifiers on every periodic tick instead of snapshotting at cast time', () => { + const result = calculateSkillDps(base({ + abilities: [directAbility({ + id: 'turret', + delivery: { type: 'periodic', ticks: 4, intervalSeconds: 1 }, + cooldownSeconds: 100, + targetCap: 1 + })], + attributes: {}, + rules: { + revisionId: 'dynamic-ticks', + bindings: [{ attributeId: 'skillDamage', stage: 'skill_damage', operator: 'add' }] + }, + modifierWindows: [{ + startSeconds: 2, + endSeconds: 4, + attributes: { skillDamage: 100 }, + abilityIds: ['turret'] + }], + assumptions: { encounterSeconds: 5, targetCount: 1 } + })); + expect(result.status).toBe('OK'); + if (result.status !== 'OK') return; + + expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(600); + expect(result.abilities[0].damage.averageExpectedDamagePerHit).toBe(150); + }); + + it('supports multiple delivered hits on each periodic tick', () => { + const result = calculateSkillDps(base({ + abilities: [directAbility({ + id: 'multi-hit-dot', + delivery: { type: 'periodic', ticks: 2, hitsPerTick: 3, intervalSeconds: 1 }, + cooldownSeconds: 100, + targetCap: 1 + })], + attributes: {}, + rules: { revisionId: 'periodic-hits', bindings: [] }, + assumptions: { encounterSeconds: 5, targetCount: 1 } + })); + expect(result.status).toBe('OK'); + if (result.status !== 'OK') return; + + expect(result.abilities[0].damageEventCount).toBe(6); + expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(600); + }); + + it('applies published cooldown recovery and duration stages to cadence', () => { + const result = calculateSkillDps(base({ + abilities: [directAbility({ + id: 'cadence', + delivery: { + type: 'periodic', + ticks: 2, + hitsPerTick: 1, + intervalSeconds: 1, + firstTickDelaySeconds: 1 + }, + cooldownSeconds: 10, + targetCap: 1 + })], + attributes: { recovery: 100, duration: 100 }, + rules: { + revisionId: 'skill-cadence', + bindings: [ + { attributeId: 'recovery', stage: 'cooldown_recovery', operator: 'add' }, + { attributeId: 'duration', stage: 'duration', operator: 'add' } + ] + }, + assumptions: { encounterSeconds: 11, targetCount: 1 } + })); + expect(result.status).toBe('OK'); + if (result.status !== 'OK') return; + + expect(result.abilities[0].activationCount).toBe(3); + expect(result.abilities[0].damageEventCount).toBe(8); + expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(800); + }); + + it('supports custom uses per minute and clips damage events at encounter end', () => { + const result = calculateSkillDps(base({ + abilities: [directAbility({ + delivery: { type: 'periodic', ticks: 3, intervalSeconds: 2 }, + cooldownSeconds: 0, + targetCap: 1, + activation: { mode: 'custom_per_minute', usesPerMinute: 4 } + })], + attributes: {}, + rules: { revisionId: 'custom-rate', bindings: [] }, + assumptions: { encounterSeconds: 31, targetCount: 1 } + })); + expect(result.status).toBe('OK'); + if (result.status !== 'OK') return; + + expect(result.abilities[0].activationCount).toBe(3); + expect(result.abilities[0].damageEventCount).toBe(7); + expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(700); + }); + + it('models independently recharging charges and full-set recharge differently', () => { + const shared = { + delivery: { type: 'direct', hits: 1 } as const, + castSeconds: 2, + cooldownSeconds: 10, + targetCap: 1, + activation: { mode: 'on_cooldown' } as const + }; + const result = calculateSkillDps(base({ + abilities: [ + directAbility({ + ...shared, + id: 'independent', + chargePolicy: { type: 'independent', charges: 3 } + }), + directAbility({ + ...shared, + id: 'full-set', + chargePolicy: { type: 'full_set', charges: 3 } + }) + ], + attributes: {}, + rules: { revisionId: 'charges', bindings: [] }, + assumptions: { encounterSeconds: 13, targetCount: 1 } + })); + expect(result.status).toBe('OK'); + if (result.status !== 'OK') return; + + expect(result.abilities.find((item) => item.abilityId === 'independent')?.activationCount).toBe(5); + expect(result.abilities.find((item) => item.abilityId === 'full-set')?.activationCount).toBe(3); + }); + + it('combines every enabled ability and excludes disabled abilities', () => { + const result = calculateSkillDps(base({ + abilities: [ + directAbility({ id: 'one', delivery: { type: 'direct', hits: 1 } }), + directAbility({ id: 'two', delivery: { type: 'direct', hits: 1 }, baseDamage: 200 }), + directAbility({ + id: 'off', + baseDamage: undefined, + activation: { mode: 'off' } + }) + ], + attributes: {}, + rules: { revisionId: 'combined', bindings: [] }, + assumptions: { encounterSeconds: 10, targetCount: 1 } + })); + expect(result.status).toBe('OK'); + if (result.status !== 'OK') return; + + expect(result.abilities.find((item) => item.abilityId === 'off')).toMatchObject({ + enabled: false, + activationCount: 0 + }); + expect(result.combined.singleTargetEncounterDamage).toBe(600); + expect(result.combined.singleTargetDps).toBe(60); + }); + + it('uses each ability target assumption before applying its target cap', () => { + const result = calculateSkillDps(base({ + abilities: [ + directAbility({ id: 'single', targetCount: 1 }), + directAbility({ id: 'crowd', targetCount: 2 }) + ], + attributes: {}, + rules: { revisionId: 'per-ability-targets', bindings: [] }, + assumptions: { encounterSeconds: 10, targetCount: 5 } + })); + expect(result.status).toBe('OK'); + if (result.status !== 'OK') return; + + expect(result.abilities.find((item) => item.abilityId === 'single')?.effectiveTargets).toBe(1); + expect(result.abilities.find((item) => item.abilityId === 'crowd')?.effectiveTargets).toBe(2); + }); + + it('returns explicit missing requirements instead of partial damage', () => { + const result = calculateSkillDps({ + abilities: [directAbility({ + baseDamage: undefined, + targetCap: undefined, + chargePolicy: undefined, + activation: { mode: 'custom_per_minute', usesPerMinute: 0 } + })], + attributes: { unknownSkillAttribute: 10 }, + assumptions: {} + }); + expect(result).toMatchObject({ + status: 'INSUFFICIENT_DATA', + revisionId: null, + abilities: [], + combined: null + }); + expect(result.missing).toEqual(expect.arrayContaining([ + 'rules.revisionId', + 'rules.bindings', + 'assumptions.encounterSeconds', + 'assumptions.targetCount', + 'ability:seeker:baseDamage', + 'ability:seeker:targetCap', + 'ability:seeker:chargePolicy', + 'ability:seeker:activation.usesPerMinute', + 'binding:unknownSkillAttribute' + ])); + }); +}); diff --git a/src/lib/calculations/skill.ts b/src/lib/calculations/skill.ts new file mode 100644 index 0000000..7c8dc1a --- /dev/null +++ b/src/lib/calculations/skill.ts @@ -0,0 +1,553 @@ +export type SkillStatValues = Record; + +export type SkillCalculationStage = + | 'flat_damage' + | 'skill_damage' + | 'final_damage' + | 'critical_chance' + | 'critical_damage' + | 'multishot_chance' + | 'multishot_damage' + | 'cooldown_recovery' + | 'duration'; + +export type SkillAttributeBinding = { + attributeId: string; + stage: SkillCalculationStage; + operator: 'add' | 'multiply'; + weight?: number; +}; + +export type SkillCalculationRules = { + revisionId: string; + bindings: SkillAttributeBinding[]; + stageOrder?: SkillCalculationStage[]; +}; + +export type SkillDelivery = + | { type: 'direct'; hits: number } + | { + type: 'periodic'; + ticks: number; + durationSeconds?: number; + hitsPerTick?: number; + intervalSeconds: number; + firstTickDelaySeconds?: number; + }; + +export type SkillChargePolicy = + | { type: 'none' } + | { type: 'independent'; charges: number } + | { type: 'full_set'; charges: number }; + +export type SkillActivation = + | { mode: 'off' } + | { mode: 'on_cooldown' } + | { mode: 'custom_per_minute'; usesPerMinute: number }; + +export type SkillAbilityInput = { + id: string; + baseDamage?: number; + damageType?: string; + delivery: SkillDelivery; + castSeconds?: number; + cooldownSeconds?: number; + targetCap?: number; + targetCount?: number; + chargePolicy?: SkillChargePolicy; + activation: SkillActivation; +}; + +export type SkillModifierWindow = { + startSeconds: number; + endSeconds: number; + attributes: SkillStatValues; + abilityIds?: string[]; +}; + +export type SkillCalculationInput = { + rules?: SkillCalculationRules; + abilities: SkillAbilityInput[]; + attributes?: SkillStatValues; + attributeRules?: Record; + modifierWindows?: SkillModifierWindow[]; + assumptions: { + encounterSeconds?: number; + targetCount?: number; + }; +}; + +export type SkillDamageSummary = { + unmodifiedBaseDamage: number; + averageExpectedDamagePerHit: number; + singleTargetDamagePerActivation: number; + aggregateDamagePerActivation: number; + singleTargetEncounterDamage: number; + aggregateEncounterDamage: number; + singleTargetDps: number; + aggregateDps: number; +}; + +export type SkillAbilityCalculation = { + abilityId: string; + damageType: string; + enabled: boolean; + activationCount: number; + damageEventCount: number; + effectiveTargets: number; + damage: SkillDamageSummary; +}; + +export type SkillCombinedCalculation = { + singleTargetEncounterDamage: number; + aggregateEncounterDamage: number; + singleTargetDps: number; + aggregateDps: number; +}; + +export type SkillCalculation = + | { + status: 'INSUFFICIENT_DATA'; + revisionId: string | null; + missing: string[]; + abilities: []; + combined: null; + } + | { + status: 'OK'; + revisionId: string; + missing: []; + abilities: SkillAbilityCalculation[]; + combined: SkillCombinedCalculation; + }; + +type DamageEvent = { + activationIndex: number; + time: number; +}; + +const EPSILON = 0.000_001; +const MAX_EVENTS = 100_000; + +const isFiniteNumber = (value: unknown) => Number.isFinite(Number(value)); +const isNonNegative = (value: unknown) => isFiniteNumber(value) && Number(value) >= 0; +const isPositive = (value: unknown) => isFiniteNumber(value) && Number(value) > 0; +const isPositiveInteger = (value: unknown) => Number.isInteger(Number(value)) && Number(value) > 0; +const clamp = (value: number, minimum: number, maximum: number) => + Math.min(maximum, Math.max(minimum, value)); + +function requiredData(input: SkillCalculationInput) { + const missing: string[] = []; + const enabled = input.abilities.filter((ability) => ability.activation.mode !== 'off'); + + if (enabled.length) { + if (!input.rules?.revisionId?.trim()) missing.push('rules.revisionId'); + if (!Array.isArray(input.rules?.bindings)) missing.push('rules.bindings'); + if (!isPositive(input.assumptions.encounterSeconds)) missing.push('assumptions.encounterSeconds'); + if (!isPositiveInteger(input.assumptions.targetCount)) missing.push('assumptions.targetCount'); + } + + const seenIds = new Set(); + for (const ability of enabled) { + const prefix = `ability:${ability.id || 'unknown'}`; + if (!ability.id.trim()) missing.push(`${prefix}:id`); + if (seenIds.has(ability.id)) missing.push(`${prefix}:duplicate`); + seenIds.add(ability.id); + if (!isPositive(ability.baseDamage)) missing.push(`${prefix}:baseDamage`); + if (!ability.damageType?.trim()) missing.push(`${prefix}:damageType`); + if (!isNonNegative(ability.castSeconds)) missing.push(`${prefix}:castSeconds`); + if (!isNonNegative(ability.cooldownSeconds)) missing.push(`${prefix}:cooldownSeconds`); + if (!isPositiveInteger(ability.targetCap)) missing.push(`${prefix}:targetCap`); + if (ability.targetCount !== undefined && !isPositiveInteger(ability.targetCount)) { + missing.push(`${prefix}:targetCount`); + } + if (!ability.chargePolicy) missing.push(`${prefix}:chargePolicy`); + if (ability.chargePolicy?.type !== 'none' && !isPositiveInteger(ability.chargePolicy?.charges)) { + missing.push(`${prefix}:chargePolicy.charges`); + } + if (ability.delivery.type === 'direct') { + if (!isPositiveInteger(ability.delivery.hits)) missing.push(`${prefix}:delivery.hits`); + } else { + if (!isPositiveInteger(ability.delivery.ticks)) missing.push(`${prefix}:delivery.ticks`); + if ( + ability.delivery.durationSeconds !== undefined + && !isPositive(ability.delivery.durationSeconds) + ) { + missing.push(`${prefix}:delivery.durationSeconds`); + } + if (!isPositiveInteger(ability.delivery.hitsPerTick ?? 1)) { + missing.push(`${prefix}:delivery.hitsPerTick`); + } + if (!isNonNegative(ability.delivery.intervalSeconds)) { + missing.push(`${prefix}:delivery.intervalSeconds`); + } + if (!isNonNegative(ability.delivery.firstTickDelaySeconds ?? 0)) { + missing.push(`${prefix}:delivery.firstTickDelaySeconds`); + } + } + if ( + ability.activation.mode === 'custom_per_minute' + && !isPositive(ability.activation.usesPerMinute) + ) { + missing.push(`${prefix}:activation.usesPerMinute`); + } + if ( + ability.activation.mode === 'on_cooldown' + && !isPositive(ability.cooldownSeconds) + && !isPositive(ability.castSeconds) + ) { + missing.push(`${prefix}:activation.cadence`); + } + } + + for (const [index, window] of (input.modifierWindows ?? []).entries()) { + if (!isNonNegative(window.startSeconds)) { + missing.push(`modifierWindow:${index}:startSeconds`); + } + if (!isPositive(window.endSeconds) || window.endSeconds <= window.startSeconds) { + missing.push(`modifierWindow:${index}:endSeconds`); + } + for (const [attributeId, value] of Object.entries(window.attributes)) { + if (!isFiniteNumber(value)) missing.push(`modifierWindow:${index}:attribute:${attributeId}`); + } + } + + for (const [attributeId, value] of Object.entries(input.attributes ?? {})) { + if (!isFiniteNumber(value)) missing.push(`attribute:${attributeId}`); + } + + const boundIds = new Set(input.rules?.bindings.map((binding) => binding.attributeId) ?? []); + const suppliedIds = new Set([ + ...Object.keys(input.attributes ?? {}), + ...(input.modifierWindows ?? []).flatMap((window) => Object.keys(window.attributes)) + ]); + for (const attributeId of suppliedIds) { + if (!boundIds.has(attributeId)) missing.push(`binding:${attributeId}`); + } + + return [...new Set(missing)]; +} + +function activationInterval(ability: SkillAbilityInput) { + return ability.activation.mode === 'custom_per_minute' + ? 60 / ability.activation.usesPerMinute + : 0; +} + +function stageFactorAt( + input: SkillCalculationInput, + rules: SkillCalculationRules, + abilityId: string, + time: number, + stage: 'cooldown_recovery' | 'duration' +) { + const values = stageValues(attributesAt(input, abilityId, time), rules.bindings, stage); + return Math.max(EPSILON, (1 + values.additive / 100) * values.multiplicative); +} + +function activationTimes( + ability: SkillAbilityInput, + encounterSeconds: number, + input: SkillCalculationInput, + rules: SkillCalculationRules +) { + if (ability.activation.mode === 'off') return []; + + const castSeconds = Number(ability.castSeconds); + const cooldownAt = (time: number) => Number(ability.cooldownSeconds) + / stageFactorAt(input, rules, ability.id, time, 'cooldown_recovery'); + const requestedInterval = activationInterval(ability); + const policy = ability.chargePolicy!; + const times: number[] = []; + let lastActivation = -Infinity; + let nextRequested = 0; + + if (policy.type === 'none') { + for ( + let time = 0; + time < encounterSeconds - EPSILON && times.length < MAX_EVENTS; + time += Math.max(castSeconds, cooldownAt(time), requestedInterval) + ) { + times.push(time); + } + return times; + } + + if (policy.type === 'independent') { + const readyAt = Array.from({ length: policy.charges }, () => 0); + while (times.length < MAX_EVENTS) { + const chargeIndex = readyAt.reduce( + (earliest, ready, index) => ready < readyAt[earliest] ? index : earliest, + 0 + ); + const time = Math.max( + lastActivation + castSeconds, + readyAt[chargeIndex], + ability.activation.mode === 'custom_per_minute' ? nextRequested : 0 + ); + if (time >= encounterSeconds - EPSILON) break; + times.push(time); + lastActivation = time; + readyAt[chargeIndex] = time + cooldownAt(time); + if (ability.activation.mode === 'custom_per_minute') nextRequested = time + requestedInterval; + } + return times; + } + + let charges = policy.charges; + let fullRechargeAt = Infinity; + while (times.length < MAX_EVENTS) { + let time = Math.max( + lastActivation + castSeconds, + ability.activation.mode === 'custom_per_minute' ? nextRequested : 0 + ); + if (charges === 0) { + time = Math.max(time, fullRechargeAt); + charges = policy.charges; + fullRechargeAt = Infinity; + } + if (time >= encounterSeconds - EPSILON) break; + times.push(time); + lastActivation = time; + charges -= 1; + if (charges === 0) fullRechargeAt = time + cooldownAt(time); + if (ability.activation.mode === 'custom_per_minute') nextRequested = time + requestedInterval; + } + return times; +} + +function damageEvents( + ability: SkillAbilityInput, + activations: number[], + encounterSeconds: number, + input: SkillCalculationInput, + rules: SkillCalculationRules +) { + const events: DamageEvent[] = []; + for (const [activationIndex, activationTime] of activations.entries()) { + const deliveryTime = activationTime + Number(ability.castSeconds); + if (ability.delivery.type === 'direct') { + for (let hit = 0; hit < ability.delivery.hits; hit += 1) { + if (deliveryTime < encounterSeconds - EPSILON) { + events.push({ activationIndex, time: deliveryTime }); + } + } + continue; + } + const firstTick = deliveryTime + Number(ability.delivery.firstTickDelaySeconds ?? 0); + const durationFactor = stageFactorAt(input, rules, ability.id, activationTime, 'duration'); + const baseDuration = Number( + ability.delivery.durationSeconds + ?? ability.delivery.ticks * ability.delivery.intervalSeconds + ); + const ticks = Math.max( + 1, + Math.floor(baseDuration * durationFactor / ability.delivery.intervalSeconds) + ); + for (let tick = 0; tick < ticks; tick += 1) { + const time = firstTick + tick * ability.delivery.intervalSeconds; + if (time < encounterSeconds - EPSILON) { + for (let hit = 0; hit < Number(ability.delivery.hitsPerTick ?? 1); hit += 1) { + events.push({ activationIndex, time }); + } + } + } + } + return events; +} + +function attributesAt( + input: SkillCalculationInput, + abilityId: string, + time: number +) { + const attributes = { ...(input.attributes ?? {}) }; + for (const window of input.modifierWindows ?? []) { + const appliesToAbility = !window.abilityIds?.length || window.abilityIds.includes(abilityId); + if (!appliesToAbility || time < window.startSeconds || time >= window.endSeconds) continue; + for (const [attributeId, value] of Object.entries(window.attributes)) { + const current = attributes[attributeId] ?? 0; + const rule = input.attributeRules?.[attributeId]; + const combined = rule?.combination === 'multiplicative_percent' + ? ((1 + current / 100) * (1 + value / 100) - 1) * 100 + : current + value; + attributes[attributeId] = rule?.cap === null || rule?.cap === undefined + ? combined + : Math.min(combined, rule.cap); + } + } + return attributes; +} + +function stageValues( + attributes: SkillStatValues, + bindings: SkillAttributeBinding[], + stage: SkillCalculationStage +) { + let additive = 0; + let multiplicative = 1; + for (const binding of bindings) { + if (binding.stage !== stage) continue; + const raw = Number(attributes[binding.attributeId] ?? 0); + const value = raw * (Number.isFinite(binding.weight) ? Number(binding.weight) : 1); + if (binding.operator === 'add') additive += value; + else multiplicative *= value; + } + return { additive, multiplicative }; +} + +function expectedDamage( + baseDamage: number, + attributes: SkillStatValues, + rules: SkillCalculationRules +) { + let damage = baseDamage; + const stageOrder = rules.stageOrder?.length + ? rules.stageOrder + : ['flat_damage', 'skill_damage', 'final_damage'] satisfies SkillCalculationStage[]; + + for (const stage of stageOrder) { + const values = stageValues(attributes, rules.bindings, stage); + if (stage === 'flat_damage') { + damage = (damage + values.additive) * values.multiplicative; + } else if (stage === 'skill_damage' || stage === 'final_damage') { + damage *= (1 + values.additive / 100) * values.multiplicative; + } + } + + const criticalChance = stageValues(attributes, rules.bindings, 'critical_chance'); + const criticalDamage = stageValues(attributes, rules.bindings, 'critical_damage'); + const multishotChance = stageValues(attributes, rules.bindings, 'multishot_chance'); + const multishotDamage = stageValues(attributes, rules.bindings, 'multishot_damage'); + const criticalChanceRate = clamp( + criticalChance.additive * criticalChance.multiplicative / 100, + 0, + 1 + ); + const criticalDamageBonus = + criticalDamage.additive * criticalDamage.multiplicative / 100; + const multishotChanceRate = clamp( + multishotChance.additive * multishotChance.multiplicative / 100, + 0, + 1 + ); + const multishotDamageFactor = + multishotDamage.additive * multishotDamage.multiplicative / 100; + + return damage + * (1 + criticalChanceRate * criticalDamageBonus) + * (1 + multishotChanceRate * multishotDamageFactor); +} + +function zeroDamage(baseDamage = 0): SkillDamageSummary { + return { + unmodifiedBaseDamage: baseDamage, + averageExpectedDamagePerHit: 0, + singleTargetDamagePerActivation: 0, + aggregateDamagePerActivation: 0, + singleTargetEncounterDamage: 0, + aggregateEncounterDamage: 0, + singleTargetDps: 0, + aggregateDps: 0 + }; +} + +export function calculateSkillDps(input: SkillCalculationInput): SkillCalculation { + const missing = requiredData(input); + if (missing.length) { + return { + status: 'INSUFFICIENT_DATA', + revisionId: input.rules?.revisionId ?? null, + missing, + abilities: [], + combined: null + }; + } + + const rules = input.rules!; + const encounterSeconds = Number(input.assumptions.encounterSeconds); + const targetCount = Number(input.assumptions.targetCount); + const abilities: SkillAbilityCalculation[] = input.abilities.map((ability) => { + if (ability.activation.mode === 'off') { + return { + abilityId: ability.id, + damageType: ability.damageType ?? '', + enabled: false, + activationCount: 0, + damageEventCount: 0, + effectiveTargets: 0, + damage: zeroDamage(Number(ability.baseDamage ?? 0)) + }; + } + + const activations = activationTimes(ability, encounterSeconds, input, rules); + const events = damageEvents(ability, activations, encounterSeconds, input, rules); + const damageByEvent = events.map((event) => + expectedDamage( + Number(ability.baseDamage), + attributesAt(input, ability.id, event.time), + rules + ) + ); + const singleTargetEncounterDamage = damageByEvent.reduce((total, damage) => total + damage, 0); + const effectiveTargets = Math.min( + Number(ability.targetCount ?? targetCount), + Number(ability.targetCap) + ); + const aggregateEncounterDamage = singleTargetEncounterDamage * effectiveTargets; + const deliveredActivations = new Set(events.map((event) => event.activationIndex)).size; + const averageExpectedDamagePerHit = damageByEvent.length + ? singleTargetEncounterDamage / damageByEvent.length + : 0; + const singleTargetDamagePerActivation = deliveredActivations + ? singleTargetEncounterDamage / deliveredActivations + : 0; + + return { + abilityId: ability.id, + damageType: ability.damageType!, + enabled: true, + activationCount: activations.length, + damageEventCount: events.length, + effectiveTargets, + damage: { + unmodifiedBaseDamage: Number(ability.baseDamage), + averageExpectedDamagePerHit, + singleTargetDamagePerActivation, + aggregateDamagePerActivation: singleTargetDamagePerActivation * effectiveTargets, + singleTargetEncounterDamage, + aggregateEncounterDamage, + singleTargetDps: singleTargetEncounterDamage / encounterSeconds, + aggregateDps: aggregateEncounterDamage / encounterSeconds + } + }; + }); + + const combined = abilities.reduce( + (total, ability) => ({ + singleTargetEncounterDamage: + total.singleTargetEncounterDamage + ability.damage.singleTargetEncounterDamage, + aggregateEncounterDamage: + total.aggregateEncounterDamage + ability.damage.aggregateEncounterDamage, + singleTargetDps: total.singleTargetDps + ability.damage.singleTargetDps, + aggregateDps: total.aggregateDps + ability.damage.aggregateDps + }), + { + singleTargetEncounterDamage: 0, + aggregateEncounterDamage: 0, + singleTargetDps: 0, + aggregateDps: 0 + } + ); + + return { + status: 'OK', + revisionId: rules.revisionId, + missing: [], + abilities, + combined + }; +} diff --git a/src/lib/components/MechanicLanguageHelp.svelte b/src/lib/components/MechanicLanguageHelp.svelte new file mode 100644 index 0000000..96eff39 --- /dev/null +++ b/src/lib/components/MechanicLanguageHelp.svelte @@ -0,0 +1,88 @@ + + + + + +
+
+
+

ISAC SCRIPT LANGUAGE

+

How calculation scripts work

+
+ +
+
+

ISAC generates these scripts from structured controls. You review the script, while typed data remains the calculation source.

+
+
WHEN
The event that begins a mechanic branch.
+
IF
Conditions checked against the event, Agent, target, team, or encounter.
+
TRACK
A count, duration, scalar, mark, or stack pool shared by branches.
+
ADD / SET / CONSUME
How a matching branch changes tracked state.
+
APPLY
The Attribute or direct result and the phase when it takes effect.
+
UNTIL
The expiry, reset, or transfer policy for tracked state.
+
EVERY
An explicit periodic interval. ISAC does not infer a missing cadence.
+
PHASE
Whether an outcome runs before state changes, after them, or while state remains active.
+
+ +
+

Combat calculation statements

+

TAKE, ADD, APPLY, WEIGHT, and RETURN explain the ordered deterministic damage pipeline.

+
+
+ +
+
+ + diff --git a/src/lib/components/ProposalChangePreview.svelte b/src/lib/components/ProposalChangePreview.svelte index e6e08fb..7a2b01c 100644 --- a/src/lib/components/ProposalChangePreview.svelte +++ b/src/lib/components/ProposalChangePreview.svelte @@ -105,6 +105,8 @@ const proposedEffects = $derived((jsonValue(proposed.effectsJson) ?? []) as any[]); const proposedRanks = $derived((jsonValue(proposed.ranksJson) ?? []) as any[]); const currentAbilityEffects = $derived((currentSnapshot?.effects ?? []) as any[]); + const currentAbilityAttack = $derived((currentSnapshot?.attack ?? null) as Record | null); + const proposedAbilityAttack = $derived((jsonValue(proposed.attackJson) ?? null) as Record | null); const currentNodeRanks = $derived((currentSnapshot?.ranks ?? []) as any[]); const currentNodeEffects = $derived((currentSnapshot?.effects ?? []) as any[]); const currentNodeCosts = $derived((currentSnapshot?.costs ?? []) as any[]); @@ -112,11 +114,129 @@ const calculationRecord = $derived((currentSnapshot?.record ?? null) as Record | null); const proposedCalculationStages = $derived((jsonValue(proposed.stagesJson) ?? []) as any[]); const proposedCalculationProfiles = $derived((jsonValue(proposed.profilesJson) ?? []) as any[]); + const proposedSkillCalculationProfiles = $derived((jsonValue(proposed.skillProfilesJson) ?? []) as any[]); const effectSummary = (effect: any) => { const target = effect.statId || effect.stat_attribute_id || effect.abilityId || effect.ability_id || effect.label || 'CUSTOM EFFECT'; const value = effect.value === null || effect.value === undefined || effect.value === '' ? '' : `${effect.value}${effect.unit ? ` ${effect.unit}` : ''}`; return `${target}${value ? ` · ${value}` : ''}`; }; + const conditionalRuleSummary = (effect: any) => { + const rule = effect.conditionalRule ?? {}; + const clauses = Array.isArray(rule.clauses) ? rule.clauses : []; + const outcomes = clauses.reduce((total: number, clause: any) => + total + (Array.isArray(clause.outcomes) ? clause.outcomes.length : 0), 0); + return `${effect.effectId} · ${readableToken(rule.simulationReadiness)} · ${clauses.length} BRANCH${clauses.length === 1 ? '' : 'ES'} · ${outcomes} OUTCOME${outcomes === 1 ? '' : 'S'}`; + }; + const readableToken = (value: unknown) => shown(value).replaceAll('_', ' ').toUpperCase(); + const storedJson = (value: unknown, fallback: unknown) => { + if (typeof value !== 'string') return value ?? fallback; + try { return JSON.parse(value); } catch { return fallback; } + }; + const attackProfileLines = (attack: Record | null) => { + if (!attack || ('enabled' in attack && !attack.enabled)) return []; + const delivery = String(attack.delivery ?? '').toLowerCase(); + const baseDamage = attack.baseDamage ?? attack.base_damage; + const damageType = attack.damageType ?? attack.damage_type; + const hits = attack.hitsPerActivation ?? attack.hits_per_activation; + const castSeconds = attack.castSeconds ?? attack.cast_seconds; + const targetCap = attack.targetCap ?? attack.target_cap; + const chargeCount = attack.chargeCount ?? attack.charge_count; + const chargePolicy = attack.chargePolicy ?? attack.charge_policy; + const lines = [ + `BASE DAMAGE · ${shown(baseDamage)} · ${readableToken(damageType)}`, + `DELIVERY · ${readableToken(delivery)} · ${shown(hits)} HIT${Number(hits) === 1 ? '' : 'S'} PER ACTIVATION · TARGET CAP ${shown(targetCap)}`, + `TIMING · ${shown(castSeconds)}s CAST`, + `CHARGES · ${shown(chargeCount)} · ${readableToken(chargePolicy)}` + ]; + if (delivery === 'periodic') { + const interval = attack.tickIntervalSeconds ?? attack.tick_interval_seconds; + const duration = attack.damageDurationSeconds ?? attack.damage_duration_seconds; + lines.splice(3, 0, `PERIODIC WINDOW · EVERY ${shown(interval)}s FOR ${shown(duration)}s`); + } + return lines; + }; + const conditionalRuleLines = (source: unknown) => { + if (!source || typeof source !== 'object' || Array.isArray(source)) return []; + const wrapper = source as Record; + const persisted = wrapper.rule && typeof wrapper.rule === 'object'; + const rule = persisted ? wrapper.rule : wrapper; + const inputs = persisted + ? storedJson(rule.scenario_inputs_json, []) + : rule.scenarioInputs; + const pools = persisted ? wrapper.statePools : rule.pools; + const clauses = persisted ? wrapper.clauses : rule.clauses; + const lines = [ + `READINESS · ${readableToken(rule.simulationReadiness ?? rule.simulation_readiness)}`, + `MODEL · ${Array.isArray(inputs) ? inputs.length : 0} SCENARIO INPUTS · ${Array.isArray(pools) ? pools.length : 0} STATE POOLS · ${Array.isArray(clauses) ? clauses.length : 0} BRANCHES` + ]; + if (Array.isArray(inputs)) { + for (const input of inputs) { + lines.push(`INPUT · ${shown(input.label ?? input.key)} · ${readableToken(input.kind)} · ${readableToken(input.readiness ?? 'ready')}`); + } + } + if (Array.isArray(pools)) { + for (const pool of pools) { + const poolKey = pool.key ?? pool.state_key; + const scope = pool.scope ?? pool.state_scope; + const maximum = pool.max ?? pool.maximum_value; + const expiry = pool.expiresAfterSeconds ?? pool.duration_seconds; + lines.push(`STATE · ${shown(poolKey)} · ${readableToken(scope)} · MAX ${shown(maximum)}${expiry ? ` · EXPIRES ${expiry}s` : ''}`); + } + } + if (Array.isArray(clauses)) { + for (const [index, clause] of clauses.entries()) { + const event = clause.event ?? clause.event_kind; + const recipe = clause.recipeKey ?? clause.recipe_key; + const reducer = persisted + ? storedJson(clause.reducer_json, { kind: 'none' }) + : clause.reducer; + const mutations = persisted + ? storedJson(clause.mutations_json, []) + : clause.mutations; + const branchOutcomes = persisted + ? (wrapper.outcomes ?? []).filter((outcome: any) => outcome.clause_id === clause.id) + : clause.outcomes ?? []; + lines.push([ + `BRANCH ${index + 1}`, + readableToken(event), + recipe ? `RECIPE ${readableToken(recipe)}` : '', + `REDUCER ${readableToken(reducer?.kind)}`, + clause.cooldownSeconds ?? clause.cooldown_seconds + ? `${clause.cooldownSeconds ?? clause.cooldown_seconds}s COOLDOWN` + : '', + `${Array.isArray(mutations) ? mutations.length : 0} MUTATIONS`, + `${branchOutcomes.length} OUTCOMES` + ].filter(Boolean).join(' · ')); + for (const outcome of branchOutcomes) { + if (persisted) { + lines.push([ + `↳ ${readableToken(outcome.variant_key)}`, + readableToken(outcome.application_kind), + readableToken(outcome.effect_scope), + outcome.stat_attribute_id || readableToken(outcome.resource_kind ?? outcome.value_basis), + `${shown(outcome.value)}${outcome.value_expression_json ? ' · EXPRESSION' : ''}`, + readableToken(outcome.application_phase) + ].join(' · ')); + continue; + } + const variants = outcome.variants && typeof outcome.variants === 'object' + ? Object.entries(outcome.variants).map(([variant, configured]: [string, any]) => + `${readableToken(variant)} ${shown(configured?.value)}${configured?.expression ? ' (EXPRESSION)' : ''}` + ).join(' · ') + : 'NO VARIANTS'; + lines.push([ + `↳ ${shown(outcome.label ?? outcome.key)}`, + readableToken(outcome.kind), + readableToken(outcome.scope), + outcome.attributeId || readableToken(outcome.resource ?? outcome.basis), + variants, + readableToken(outcome.phase) + ].join(' · ')); + } + } + } + return lines; + };
@@ -168,7 +288,10 @@
{#each skillChipFields as field}
{field.label}{shown(field.current)}
{/each}
ROLL POOLS{(currentSnapshot?.primaryOptions as any[])?.length ?? 0} PRIMARY · {(currentSnapshot?.secondaryOptions as any[])?.length ?? 0} SECONDARY
-
TALENT STRUCTURE{(currentSnapshot?.talentTriggers as any[])?.length ?? 0} TRIGGERS · {(currentSnapshot?.talentEffects as any[])?.length ?? 0} EFFECTS
+
+
+
3-PIECE CONDITIONAL MECHANICTYPED RULE
+ {#each conditionalRuleLines(currentSnapshot?.conditional) as line}

{line}

{:else}

NO TYPED RULE CONFIGURED

{/each}
{:else}
NEW SKILL CHIPNo live registry record will be replaced.
@@ -179,7 +302,10 @@
{#each skillChipFields as field}
{field.label}{shown(field.proposed)}
{/each}
ROLL POOLS{Array.isArray(proposed.primaryStatIds) ? proposed.primaryStatIds.length : Number(Boolean(proposed.primaryStatIds))} PRIMARY · {Array.isArray(proposed.secondaryStatIds) ? proposed.secondaryStatIds.length : Number(Boolean(proposed.secondaryStatIds))} SECONDARY
-
TALENT STRUCTURE{Array.isArray(proposed.triggerIds) ? proposed.triggerIds.length : Number(Boolean(proposed.triggerIds))} TRIGGERS · {((jsonValue(proposed.talentEffects) ?? []) as any[]).length} EFFECTS
+
+
+
3-PIECE CONDITIONAL MECHANICTYPED RULE
+ {#each conditionalRuleLines(jsonValue(proposed.conditionalRule)) as line}

{line}

{:else}

NO TYPED RULE CONFIGURED

{/each}
@@ -187,11 +313,11 @@
CURRENT LIVE · {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}{calculationRecord ? 'BEFORE' : 'NO LIVE RECORD'}
- {#if calculationRecord}
NAME{shown(calculationRecord.name)}
STATE{shown(calculationRecord.status)}
{#if action === 'saveRevision'}
STAGES{(currentSnapshot?.stages as any[])?.length ?? 0}
ATTRIBUTE PROFILES{(currentSnapshot?.profiles as any[])?.length ?? 0}
{/if}
{:else}
NEW {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}No live registry record will be replaced.
{/if} + {#if calculationRecord}
NAME{shown(calculationRecord.name)}
STATE{shown(calculationRecord.status)}
{#if action === 'saveRevision'}
STAGES{(currentSnapshot?.stages as any[])?.length ?? 0}
WEAPON PROFILES{(currentSnapshot?.profiles as any[])?.length ?? 0}
SKILL PROFILES{(currentSnapshot?.skillProfiles as any[])?.length ?? 0}
{/if}
{:else}
NEW {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}No live registry record will be replaced.
{/if}
PROPOSED · {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}AFTER
-
NAME{shown(proposed.name)}
{#if action === 'saveRevision'}
STAGES{proposedCalculationStages.length}
ATTRIBUTE PROFILES{proposedCalculationProfiles.length}
BASE REVISION{shown(proposed.basedOnRevisionId)}
{:else}
STATE{shown(proposed.status)}
MEASURED WEAPON{proposed.selectedWeapon === '1' ? 'SECONDARY' : 'PRIMARY'}
CORE OBSERVATIONS{['bodyHit','criticalBodyHit','headshot','criticalHeadshot','magazineSeconds','reloadSeconds'].filter((field) => proposed[field]).length} / 6
{/if}
+
NAME{shown(proposed.name)}
{#if action === 'saveRevision'}
STAGES{proposedCalculationStages.length}
WEAPON PROFILES{proposedCalculationProfiles.length}
SKILL PROFILES{proposedSkillCalculationProfiles.length}
BASE REVISION{shown(proposed.basedOnRevisionId)}
{:else}
STATE{shown(proposed.status)}
MEASURED WEAPON{proposed.selectedWeapon === '1' ? 'SECONDARY' : 'PRIMARY'}
CORE OBSERVATIONS{['bodyHit','criticalBodyHit','headshot','criticalHeadshot','magazineSeconds','reloadSeconds'].filter((field) => proposed[field]).length} / 6
{/if}
{#if action === 'saveRevision'}
{#each proposedCalculationStages as stage}
{stage.label}{stage.operator?.replaceAll('_',' ').toUpperCase()}

ORDER {stage.displayOrder} · {stage.lockedPosition?.toUpperCase() ?? 'REORDERABLE'}

{/each}
{/if}
@@ -210,6 +336,10 @@
STRUCTURED EFFECTS{currentAbilityEffects.length}
{#each currentAbilityEffects as effect}

{effectSummary(effect)}

{:else}

NO STRUCTURED EFFECTS

{/each} +
+
INTRINSIC ATTACK PROFILE{currentAbilityAttack ? 'CONFIGURED' : 'NONE'}
+ {#each attackProfileLines(currentAbilityAttack) as line}

{line}

{:else}

NO INTRINSIC ATTACK PROFILE

{/each} +
{:else if action === 'saveNode'}
{#each currentNodeRanks as rank} @@ -239,6 +369,10 @@
STRUCTURED EFFECTS{proposedEffects.length}
{#each proposedEffects as effect}

{effectSummary(effect)}

{:else}

NO STRUCTURED EFFECTS

{/each}
+
+
INTRINSIC ATTACK PROFILE{attackProfileLines(proposedAbilityAttack).length ? 'CONFIGURED' : 'NONE'}
+ {#each attackProfileLines(proposedAbilityAttack) as line}

{line}

{:else}

NO INTRINSIC ATTACK PROFILE

{/each} +
{:else if action === 'saveNode'}
{#each proposedRanks as rank} @@ -246,7 +380,7 @@
RANK {rank.rank}{rank.effects?.length ?? 0} STATIC · {rank.combatEffects?.length ?? 0} CONDITIONAL

{rank.description || 'NO RANK DESCRIPTION'}

{#each rank.effects ?? [] as effect}{effectSummary(effect)}{/each} - {#each rank.combatEffects ?? [] as effect}{effect.effectId} · {effect.triggerCount} HITS / {effect.triggerWindowSeconds}s · {effect.maxStacks} STACKS · {effect.modifiers?.length ?? 0} MODIFIERS{/each} + {#each rank.combatEffects ?? [] as effect}{conditionalRuleSummary(effect)}{/each} {Object.entries(rank.costs ?? {}).filter(([, quantity]) => Number(quantity) > 0).map(([resource, quantity]) => `${resource} × ${quantity}`).join(' · ') || 'CREDITS ONLY'}
{/each} diff --git a/src/lib/components/PseudoScript.svelte b/src/lib/components/PseudoScript.svelte new file mode 100644 index 0000000..e3596a3 --- /dev/null +++ b/src/lib/components/PseudoScript.svelte @@ -0,0 +1,54 @@ + + +
+
+ + {title} + {readiness} · READ-ONLY · GENERATED FROM TYPED DATA +
+
    + {#each lines as line} +
  1. + {#if line.trim()} + {keyword(line)}{#if remainder(line)}{' '}{remainder(line)}{/if} + {:else} + + {/if} +
  2. + {/each} +
+
+ + diff --git a/src/lib/components/builder/CombatAssumptions.svelte b/src/lib/components/builder/CombatAssumptions.svelte index eeb1743..51585b2 100644 --- a/src/lib/components/builder/CombatAssumptions.svelte +++ b/src/lib/components/builder/CombatAssumptions.svelte @@ -1,41 +1,92 @@
-

COMBAT PROFILE

-

Encounter assumptions

+

CALCULATION MODEL

+

Combat conditions

- MODELED, NOT LIVE COMBAT + {mode === 'maximum' ? 'BEST-CASE OUTPUT' : 'MODELED SCENARIO'}
-

These assumptions drive encounter-average calculations. Individual effects can override them when needed.

-
- update('encounterSeconds', value)} /> - update('hitRate', value)} /> - update('headshotRate', value)} /> - update('firingActivity', value)} /> +

{mode === 'maximum' ? 'Calculates best-case output with every configured effect at maximum stacks and selected skills used on cooldown.' : 'Uses your encounter assumptions and individual effect settings to estimate a repeatable combat scenario.'}

+
+
+ {#if mode === 'custom'} +
+
+ CUSTOM SCENARIO INPUTS + Saved with this loadout +
+
+ update('encounterSeconds', value)} /> + update('hitRate', value)} /> + update('headshotRate', value)} /> + update('shotsPerBurst', value)} /> + update('burstPauseSeconds', value)} /> + update('sameTargetSeconds', value)} /> + update('distinctTargetsPerMinute', value)} /> + update('killsPerMinute', value)} /> + update('headshotKillRate', value)} /> + update('damageTakenPerMinute', value)} /> + update('swapsPerMinute', value)} /> + update('targetDistanceMeters', value)} /> + update('healthPercent', value)} /> + +
+
+ {:else} +
+ MAXIMUM POTENTIAL ACTIVE + Effect ramp-up and encounter downtime are intentionally excluded. +
+ {/if}
diff --git a/src/lib/components/builder/ConditionalEffectCard.svelte b/src/lib/components/builder/ConditionalEffectCard.svelte index 8e6d218..b7da604 100644 --- a/src/lib/components/builder/ConditionalEffectCard.svelte +++ b/src/lib/components/builder/ConditionalEffectCard.svelte @@ -1,13 +1,9 @@ @@ -59,20 +66,51 @@
{#each facts as fact}{fact}{/each}
-
- -
- {#if effectState.mode === 'encounter'} + {#if calculationMode === 'maximum'} +
+ FULL EFFECT APPLIED + Maximum stacks · best-case uptime +
+ {:else} +
+ +
+ {/if} + {#if calculationMode === 'custom' && effectState.mode === 'encounter'}
- {#if effectState.overrides}{/if} + {#if effectState.overrides || effectState.inputs}{/if}
{#if advanced}
updateOverride('hitRate', value)} /> - updateOverride('firingActivity', value)} /> + updateOverride('shotsPerBurst', value)} /> + updateOverride('burstPauseSeconds', value)} /> + {#each scenarioInputs as input} +
+ {#if input.kind === 'toggle'} + updateScenarioInput(input.key, values[0] === 'true')} + /> + {:else} + updateScenarioInput(input.key, value)} + /> + {/if} + {#if input.description}{input.description}{/if} +
+ {/each}
{/if} {/if} @@ -89,9 +127,12 @@ header>b.active{border-color:#74552a;color:var(--shd-orange);background:#1d180e} .facts{display:flex;flex-wrap:wrap;gap:5px}.facts span{padding:4px 6px;border:1px solid var(--shd-border-soft);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:.6px} .mode-control{margin:0;padding:0;border:0}.advanced-head{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm);border-top:1px solid var(--shd-border-soft);padding-top:var(--shd-gap-sm)} + .maximum-effect{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm);padding:7px 8px;border:1px solid #74552a;background:#1d180e} + .maximum-effect strong,.maximum-effect span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.maximum-effect strong{color:var(--shd-orange)}.maximum-effect span{color:var(--shd-text-muted)} button{min-height:40px;border:1px solid var(--shd-border);background:transparent;color:var(--shd-text-muted);padding:0 10px;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;cursor:pointer;transition:var(--shd-transition)} button:hover,button:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange)}button:active{transform:translateY(1px);filter:brightness(.88)}button:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px}.clear{border-color:#563734;color:var(--shd-danger)}.clear:hover,.clear:focus-visible{border-color:var(--shd-danger);color:var(--shd-danger)} .override-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--shd-gap-md);padding:var(--shd-gap-sm);border:1px solid var(--shd-border-soft);background:var(--shd-surface-raised)} - @media(max-width:620px){header{display:grid}.override-grid{grid-template-columns:1fr}button{min-height:44px}} + .program-override{display:grid;gap:5px}.program-override small{color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4} + @media(max-width:620px){header{display:grid}.override-grid{grid-template-columns:1fr}.maximum-effect{align-items:start;flex-direction:column}button{min-height:44px}} @media(prefers-reduced-motion:reduce){button{transition:none}} diff --git a/src/lib/components/builder/SkillCalculationResult.svelte b/src/lib/components/builder/SkillCalculationResult.svelte new file mode 100644 index 0000000..a6fc0a2 --- /dev/null +++ b/src/lib/components/builder/SkillCalculationResult.svelte @@ -0,0 +1,57 @@ + + +
+
+

SKILL OUTPUT

Specialization abilities

+ {result.status === 'OK' ? 'CALCULATED' : 'INSUFFICIENT DATA'} +
+ {#if result.status === 'INSUFFICIENT_DATA'} +
+ Skill calculation unavailable + ISAC requires the following data: +
    {#each result.missing as item}
  • {item.replaceAll(':', ' · ')}
  • {/each}
+
+ {:else} +
+ SINGLE-TARGET DPS{number.format(result.combined.singleTargetDps)} + ALL-TARGET DPS{number.format(result.combined.aggregateDps)} + ENCOUNTER DAMAGE{number.format(result.combined.aggregateEncounterDamage)} +
+
+ {#each result.abilities as ability} +
+ {abilityNames[ability.abilityId] ?? ability.abilityId} + {ability.enabled ? `${ability.activationCount} ACTIVATIONS` : 'OFF'} + {number.format(ability.damage.singleTargetDps)} SINGLE DPS + {number.format(ability.damage.aggregateDps)} TOTAL DPS +
+ {/each} +
+ {/if} +
SKILLS CAN CRIT AND MULTI-SHOT · SKILLS DO NOT HEADSHOT · ENCOUNTER TRIGGERS FOLLOW THE PRIMARY WEAPON TIMELINE
+
+ + diff --git a/src/lib/components/builder/WeaponCalculationResult.svelte b/src/lib/components/builder/WeaponCalculationResult.svelte index bbfcdac..2f733e7 100644 --- a/src/lib/components/builder/WeaponCalculationResult.svelte +++ b/src/lib/components/builder/WeaponCalculationResult.svelte @@ -20,6 +20,9 @@ stacksAtEnd: number; buildUpSeconds: number | null; maintained: boolean; + uptimePercent?: number; + activationCount?: number; + instantTotal?: number; }>; missing?: string[]; revisionLabel?: string; @@ -52,6 +55,7 @@ {effect.mode.toUpperCase()} AVG {effect.averageStacks.toFixed(1)} / {effect.maxStacks} PEAK {effect.maxStacksReached} · END {effect.stacksAtEnd} + {(effect.uptimePercent ?? 0).toFixed(0)}% UPTIME · {effect.activationCount ?? 0} PROCS {effect.mode === 'off' ? 'EXCLUDED' : effect.buildUpSeconds === null ? 'MAX NOT REACHED' : `MAX IN ${effect.buildUpSeconds.toFixed(1)}s`} {effect.maintained ? 'MAINTAINED' : 'NOT MAINTAINED'} @@ -66,7 +70,7 @@ .weapon-calculation{display:grid;gap:var(--shd-gap-md);padding:var(--shd-gap-md);border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-surface)} .weapon-calculation.incomplete{border-top-color:var(--shd-danger)}header{display:flex;justify-content:space-between;gap:var(--shd-gap-md);align-items:start}header p{margin:0 0 3px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.1px}h3{margin:0;font-size:var(--shd-type-body);letter-spacing:.7px;text-transform:uppercase}header>b{padding:5px 7px;border:1px solid #74552a;background:#1d180e;color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px;white-space:nowrap}.incomplete header>b{border-color:#563734;background:#1d1212;color:var(--shd-danger)} .damage-grid,.dps-grid{display:grid;border:1px solid var(--shd-border)}.damage-grid{grid-template-columns:repeat(2,1fr)}.dps-grid{grid-template-columns:repeat(3,1fr)}.damage-grid span,.dps-grid span{display:grid;gap:4px;padding:9px;border-left:1px solid var(--shd-border);border-top:1px solid var(--shd-border)}.damage-grid span:nth-child(odd),.dps-grid span:first-child{border-left:0}.damage-grid span:nth-child(-n+2){border-top:0}.dps-grid b{color:var(--shd-orange);font-size:20px}.damage-grid b{font-size:var(--shd-type-body)}small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.missing{display:grid;gap:4px;padding:10px;border:1px solid #563734;border-left:2px solid var(--shd-danger);background:#1d1212;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.missing strong{color:var(--shd-danger);text-transform:uppercase;font-size:var(--shd-type-telemetry);letter-spacing:.8px}.missing ul{margin:2px 0 0;padding-left:18px}.missing li+li{margin-top:2px}footer{display:flex;justify-content:space-between;gap:var(--shd-gap-sm);color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:.6px} - .stack-telemetry{display:grid;border:1px solid var(--shd-model-border);background:var(--shd-model-surface)}.stack-telemetry>header{display:flex;align-items:center;justify-content:space-between;padding:6px 8px;border-bottom:1px solid var(--shd-model-border);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.stack-telemetry>header span{color:var(--shd-info)}.stack-telemetry>header b{color:var(--shd-text-muted)}.stack-telemetry>div{display:grid;grid-template-columns:minmax(120px,1.2fr) .45fr .7fr .8fr .85fr .8fr;gap:7px;align-items:center;padding:7px 8px;border-top:1px solid var(--shd-border-soft);font-size:var(--shd-type-telemetry)}.stack-telemetry>div:first-of-type{border-top:0}.stack-telemetry>div>span,.stack-telemetry>div>small,.stack-telemetry>div>em,.stack-telemetry>div>b{font-size:inherit;font-style:normal;letter-spacing:.5px}.stack-telemetry>div>span{color:var(--shd-orange);font-weight:900}.stack-telemetry>div>em{color:var(--shd-text-muted)}.stack-telemetry>div>b{color:var(--shd-danger);text-align:right}.stack-telemetry>div>b.maintained{color:var(--shd-success)} + .stack-telemetry{display:grid;border:1px solid var(--shd-model-border);background:var(--shd-model-surface)}.stack-telemetry>header{display:flex;align-items:center;justify-content:space-between;padding:6px 8px;border-bottom:1px solid var(--shd-model-border);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.stack-telemetry>header span{color:var(--shd-info)}.stack-telemetry>header b{color:var(--shd-text-muted)}.stack-telemetry>div{display:grid;grid-template-columns:minmax(120px,1.2fr) .45fr .7fr .8fr .9fr .85fr .8fr;gap:7px;align-items:center;padding:7px 8px;border-top:1px solid var(--shd-border-soft);font-size:var(--shd-type-telemetry)}.stack-telemetry>div:first-of-type{border-top:0}.stack-telemetry>div>span,.stack-telemetry>div>small,.stack-telemetry>div>em,.stack-telemetry>div>b{font-size:inherit;font-style:normal;letter-spacing:.5px}.stack-telemetry>div>span{color:var(--shd-orange);font-weight:900}.stack-telemetry>div>em{color:var(--shd-text-muted)}.stack-telemetry>div>b{color:var(--shd-danger);text-align:right}.stack-telemetry>div>b.maintained{color:var(--shd-success)} @media(max-width:820px){.stack-telemetry>div{grid-template-columns:1fr 1fr}.stack-telemetry>div>strong{grid-column:1/-1}.stack-telemetry>div>b{text-align:left}} @media(max-width:620px){header{display:grid}.dps-grid{grid-template-columns:1fr}.dps-grid span{border-left:0;border-top:1px solid var(--shd-border)}.dps-grid span:first-child{border-top:0}footer{display:grid}.damage-grid span{min-height:44px}} diff --git a/src/lib/components/isac/ConditionalOutcomeEditor.svelte b/src/lib/components/isac/ConditionalOutcomeEditor.svelte new file mode 100644 index 0000000..4f22f22 --- /dev/null +++ b/src/lib/components/isac/ConditionalOutcomeEditor.svelte @@ -0,0 +1,372 @@ + + +
+ {#each outcomes as outcome, index} +
+
+ OUTCOME {String(index + 1).padStart(2, '0')} + +
+ +
+ setApplication(index, values[0])} + required + /> + +
+ APPLIES TO + +
+ + {#if outcome.application === 'active_modifier'} +
+ ATTRIBUTE + +
+ {:else if outcome.application === 'direct_damage'} + + {:else} +
+ INSTANT EVENT + Tracked per proc and across the encounter. It does not become a permanent Attribute. +
+ {/if} + +
+ {#each valueColumns as column} + + {/each} +
+
+
+ {:else} +
+ NO CALCULATION OUTCOMES + Add at least one outcome before this conditional mechanic can affect a build. +
+ {/each} + + +
+ + diff --git a/src/lib/components/isac/MechanicProgramEditor.svelte b/src/lib/components/isac/MechanicProgramEditor.svelte new file mode 100644 index 0000000..75566b6 --- /dev/null +++ b/src/lib/components/isac/MechanicProgramEditor.svelte @@ -0,0 +1,735 @@ + + +
+ MECHANIC PROGRAM + +
+
+ STRUCTURED COMBAT LOGIC + {rule.clauses.length} {rule.clauses.length === 1 ? 'BRANCH' : 'BRANCHES'} +

Events are code-defined. Every branch, condition, state change, and outcome belongs to this item.

+
+ +
+ + + +
+
BUILDER CONTROLSSCENARIO ASSUMPTIONS
+

These controls appear in a build’s advanced mechanic settings. Maximum Potential uses the best-case value; Custom Scenario uses the Agent’s saved value.

+ {#each rule.scenarioInputs as input, index} +
+ + updateScenarioInput(index,{kind:values[0] as ScenarioInputDefinition['kind'],defaultValue:values[0] === 'toggle' ? false : Number(input.defaultValue ?? 0),bestCaseValue:values[0] === 'toggle' ? true : Number(input.bestCaseValue ?? 100),min:values[0] === 'toggle' ? undefined : input.min ?? 0,max:values[0] === 'toggle' ? undefined : input.max ?? 100})}/> + + {#if input.kind === 'toggle'} + updateScenarioInput(index,{defaultValue:values[0] === 'true'})}/> + updateScenarioInput(index,{bestCaseValue:values[0] === 'true'})}/> + {:else} + + + + + {/if} + +
+ {:else} +

No source-specific assumptions. This mechanic uses only the shared encounter timeline.

+ {/each} +
+ +
+
RECIPE STARTERSEED AN EDITABLE BRANCH
Recipes copy typed data into this item; they are not shared runtime dependencies.
+
+ + {#if ['same_target_hits_within_window', 'distinct_enemies_within_window', 'consecutive_same_target_hits'].includes(recipeKey)} + + {/if} + {#if ['same_target_hits_within_window', 'distinct_enemies_within_window', 'continuous_firing_duration'].includes(recipeKey)} + + {/if} + {#if ['target_hit_within_distance', 'within_distance'].includes(recipeKey)} + + {/if} + + {#if rule.clauses.length} + + {/if} +
+ {#if replaceConfirmation} + + {/if} +
+ +
+
STATEPOOLS
+ {#each rule.pools as pool, index} +
+ + updatePool(index,{scope:values[0] as 'agent'|'target'})}/> + + + + + +
+ {:else} +

No state pools. Add one for stacks, timers, counters, resources, or target marks.

+ {/each} +
+ +
+ {#each rule.clauses as clause, clauseIndex} +
+
+
{String(clauseIndex + 1).padStart(2, '0')}
+ +
+
+
DIRECT EVENT
+
MATCH POLICY{reducerItems.find((item) => item.id === clause.reducer.kind)?.name ?? clause.reducer.kind}
+ +
+
+ ({value:item.id,label:item.name.replace(' matches','').replace(' within a time window',' window').toUpperCase()}))} + value={clause.reducer.kind} + columns={4} + onchange={(values) => reducerKind(clauseIndex,values[0])} + /> + {#if ['count','distinct_count','consecutive_count','sliding_count'].includes(clause.reducer.kind)} + + {/if} + {#if ['distinct_count','sliding_count'].includes(clause.reducer.kind)} + + {/if} + {#if clause.reducer.kind === 'duration'} + + {:else if clause.reducer.kind === 'periodic'} + + {:else if clause.reducer.kind === 'derived_scalar'} + + {/if} +
+ +
+
STATE CHANGES{clause.mutations.length}
+ {#each clause.mutations as mutation, mutationIndex} +
+ updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'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])} + /> + {#if mutation.kind !== 'clear'} + + {/if} + +
+ {:else} +

This branch emits outcomes without changing shared state.

+ {/each} +
+ +
OUTCOMES{clause.outcomes.length}
+ {#each clause.outcomes as outcome, outcomeIndex} +
+
+ + updateOutcome(clauseIndex,outcomeIndex,{kind:values[0] as ConditionalMechanicOutcomeDraft['kind'],resource:values[0] === 'restoration' || values[0] === 'resource_cost' ? outcome.resource ?? 'health' : undefined,attributeId:values[0] === 'attribute_modifier' ? outcome.attributeId ?? '' : undefined})}/> + +
+ 4 ? 4 : scopeOptions.length} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{scope:values[0] as MechanicOutcomeScope})}/> + {#if outcome.kind === 'attribute_modifier'} + + {:else if outcome.kind === 'restoration' || outcome.kind === 'resource_cost'} + updateOutcome(clauseIndex,outcomeIndex,{resource:values[0] as 'health'|'armor'|'ammo'})}/> + {/if} + {#if outcome.kind === 'direct_damage' || outcome.kind === 'restoration' || outcome.kind === 'resource_cost'} + updateOutcome(clauseIndex,outcomeIndex,{basis:values[0] as OutcomeBasis})}/> + {/if} + setOutcomeValueModel(clauseIndex,outcomeIndex,values[0] as OutcomeValueModel,values[0] === 'event' ? 'values.enemyCount' : values[0] === 'scenario' ? rule.scenarioInputs[0]?.key ?? '' : '')} + /> + {#if outcomeValueModel(outcome) === 'event'} + setOutcomeValueModel(clauseIndex,outcomeIndex,'event',values[0])} + /> + {:else if outcomeValueModel(outcome) === 'scenario' && rule.scenarioInputs.length} + ({value:input.key,label:input.label.toUpperCase()}))} + value={outcomeReference(outcome,'scenario')} + columns={Math.min(4,rule.scenarioInputs.length)} + onchange={(values) => setOutcomeValueModel(clauseIndex,outcomeIndex,'scenario',values[0])} + /> + {/if} + {#if rule.pools.length} +
+ ({value:pool.key,label:pool.label.toUpperCase()}))]} + value={outcome.statePool ?? ''} + columns={Math.min(4,rule.pools.length + 1)} + onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{statePool:values[0] || undefined,perStack:values[0] ? outcome.perStack ?? true : false,minStacks:values[0] ? outcome.minStacks : undefined,maxStacks:values[0] ? outcome.maxStacks : undefined})} + /> + {#if outcome.statePool} + updateOutcome(clauseIndex,outcomeIndex,{perStack:values[0] === 'true'})}/> + + + {/if} +
+ {/if} +
+ {#each valueColumns as column} + + {/each} +
+ +
+ {:else} +

This branch has no outcomes yet.

+ {/each} +
+ {/each} + +
+ +
+
GENERATED MECHANICREAD-ONLY PSEUDO-SCRIPT

The server validates the same typed structure for every configured value variant.

+ +
+
+ + diff --git a/src/lib/server/audit.ts b/src/lib/server/audit.ts index 99ad538..b05b8e2 100644 --- a/src/lib/server/audit.ts +++ b/src/lib/server/audit.ts @@ -30,7 +30,6 @@ export async function auditRecord(table: string, id: string): Promise { + it('rejects divergent metadata across value variants', () => { + expect(() => conditionalRuleDraftFromRows( + rule, + [], + [clause], + [ + { ...outcome, variant_key: 'I' }, + { ...outcome, variant_key: 'II', application_phase: 'after_event', value: 5 } + ], + ['I', 'II'] + )).toThrow(/inconsistent application_phase metadata/); + }); + + it('loads consistent value variants into one outcome', () => { + const draft = conditionalRuleDraftFromRows( + rule, + [], + [clause], + [ + { ...outcome, variant_key: 'I' }, + { ...outcome, variant_key: 'II', value: 5 } + ], + ['I', 'II'] + ); + + expect(draft?.clauses[0].outcomes[0].variants).toEqual({ + I: { value: 3 }, + II: { value: 5 } + }); + }); +}); diff --git a/src/lib/server/conditional-effects.ts b/src/lib/server/conditional-effects.ts new file mode 100644 index 0000000..43e7fd0 --- /dev/null +++ b/src/lib/server/conditional-effects.ts @@ -0,0 +1,392 @@ +import { + parseConditionalMechanicDraft, + type ConditionalMechanicClauseDraft, + type ConditionalMechanicDraftV1, + type ConditionalMechanicOutcomeDraft, + type ConditionalMechanicPoolDraft +} from '$lib/calculations/mechanic-editor'; +import type { MechanicOutcome, MechanicResource, NumericExpression } from '$lib/calculations/mechanic'; +import type { Database } from '@tursodatabase/database'; +import { randomUUID } from 'node:crypto'; + +const sourceTypes = [ + 'talent', + 'os_protocol_talent', + 'skill_chip_talent', + 'specialization_tree_rank' +] as const; + +export type ConditionalSourceType = (typeof sourceTypes)[number]; +export type ConditionalValueColumn = { key: string; variantKey: string }; + +type RuleRow = Record; +type PoolRow = Record; +type ClauseRow = Record; +type OutcomeRow = Record; + +const parseJson = (value: unknown, fallback: T): T => { + try { + const parsed = JSON.parse(String(value ?? '')); + return parsed === null || parsed === undefined ? fallback : parsed as T; + } catch { + return fallback; + } +}; + +const applicationKind = (outcome: ConditionalMechanicOutcomeDraft) => { + if (outcome.kind === 'attribute_modifier') return 'active_modifier'; + if (outcome.kind === 'direct_damage') return 'direct_damage'; + if (outcome.kind === 'resource_cost') return 'resource_cost'; + if (outcome.kind === 'marker') return 'marker'; + if (outcome.resource === 'armor') return 'restore_armor'; + if (outcome.resource === 'ammo') return 'restore_ammo'; + return 'restore_health'; +}; + +const outcomeKind = (row: OutcomeRow): MechanicOutcome['kind'] => { + if (row.application_kind === 'active_modifier') return 'attribute_modifier'; + if (row.application_kind === 'direct_damage') return 'direct_damage'; + if (row.application_kind === 'resource_cost') return 'resource_cost'; + if (row.application_kind === 'marker') return 'marker'; + return 'restoration'; +}; + +const outcomeResource = (row: OutcomeRow): MechanicResource | undefined => { + if (row.resource_kind === 'health' || row.resource_kind === 'armor' || row.resource_kind === 'ammo') { + return row.resource_kind; + } + if (row.application_kind === 'restore_health') return 'health'; + if (row.application_kind === 'restore_armor') return 'armor'; + if (row.application_kind === 'restore_ammo') return 'ammo'; + return undefined; +}; + +const outcomeMetadataColumns = [ + 'outcome_key', + 'application_kind', + 'effect_scope', + 'stat_attribute_id', + 'value_basis', + 'value_scaling', + 'damage_basis', + 'application_phase', + 'combat_timing', + 'resource_kind', + 'state_pool_id', + 'min_state_value', + 'max_state_value', + 'description_slot' +] as const; + +const comparableMetadataValue = (value: unknown) => value === undefined ? null : value; + +function assertVariantMetadataConsistent(effectKey: string, variants: OutcomeRow[]) { + const first = variants[0]; + for (const variant of variants.slice(1)) { + const mismatch = outcomeMetadataColumns.find((column) => + comparableMetadataValue(variant[column]) !== comparableMetadataValue(first[column]) + ); + if (mismatch) { + throw new Error( + `Conditional outcome "${effectKey}" has inconsistent ${mismatch} metadata across value variants.` + ); + } + } +} + +export function parseConditionalRulePayload( + raw: FormDataEntryValue | string | null, + valueColumns: ConditionalValueColumn[], + options: { + allowedAttributeIds?: ReadonlySet; + allowedRecipeKeys?: ReadonlySet; + programId?: string; + programName?: string; + } = {} +) { + const result = parseConditionalMechanicDraft(raw, { + variantKeys: valueColumns.map((column) => column.variantKey), + allowedAttributeIds: options.allowedAttributeIds, + allowedRecipeKeys: options.allowedRecipeKeys, + programId: options.programId, + programName: options.programName + }); + if (!result.ok) throw new Error(result.errors.join(' ')); + return result.value.draft; +} + +export function conditionalRuleDraftFromRows( + rule: RuleRow | null | undefined, + poolRows: PoolRow[], + clauseRows: ClauseRow[], + outcomeRows: OutcomeRow[], + variantKeys: readonly string[] +): ConditionalMechanicDraftV1 | null { + if (!rule) return null; + const pools = poolRows + .filter((row) => row.rule_id === rule.id) + .sort((left, right) => Number(left.sort_order) - Number(right.sort_order)) + .map((row): ConditionalMechanicPoolDraft => ({ + key: String(row.state_key), + label: String(row.label), + stateKind: String(row.state_kind) as ConditionalMechanicPoolDraft['stateKind'], + scope: String(row.state_scope) as ConditionalMechanicPoolDraft['scope'], + initial: Number(row.initial_value), + ...(row.minimum_value === null || row.minimum_value === undefined ? {} : { min: Number(row.minimum_value) }), + ...(row.maximum_value === null || row.maximum_value === undefined ? {} : { max: Number(row.maximum_value) }), + ...(row.best_case_value === null || row.best_case_value === undefined ? {} : { bestCaseValue: Number(row.best_case_value) }), + ...(row.duration_seconds === null || row.duration_seconds === undefined ? {} : { expiresAfterSeconds: Number(row.duration_seconds) }) + })); + const poolKeyById = new Map(poolRows.map((row) => [String(row.id), String(row.state_key)])); + const rowsByClause = new Map(); + for (const row of outcomeRows.filter((item) => item.rule_id === rule.id)) { + const key = String(row.clause_id ?? ''); + rowsByClause.set(key, [...(rowsByClause.get(key) ?? []), row]); + } + const clauses = clauseRows + .filter((row) => row.rule_id === rule.id && Number(row.active ?? 1) === 1) + .sort((left, right) => Number(left.sort_order) - Number(right.sort_order)) + .map((row): ConditionalMechanicClauseDraft => { + const grouped = new Map(); + for (const outcome of rowsByClause.get(String(row.id)) ?? []) { + const key = String(outcome.effect_key); + grouped.set(key, [...(grouped.get(key) ?? []), outcome]); + } + const outcomes = [...grouped.entries()].map(([key, variants]): ConditionalMechanicOutcomeDraft => { + assertVariantMetadataConsistent(key, variants); + const first = variants[0]; + const resource = outcomeResource(first); + return { + key, + label: String(first.outcome_key || key), + phase: String(first.application_phase) as ConditionalMechanicOutcomeDraft['phase'], + kind: outcomeKind(first), + scope: String(first.effect_scope) as ConditionalMechanicOutcomeDraft['scope'], + basis: String(first.value_basis) as ConditionalMechanicOutcomeDraft['basis'], + ...(resource ? { resource } : {}), + ...(first.combat_timing ? { combatTiming: String(first.combat_timing) as 'pre_event' | 'current' } : {}), + ...(first.stat_attribute_id ? { attributeId: String(first.stat_attribute_id) } : {}), + ...(first.state_pool_id && poolKeyById.has(String(first.state_pool_id)) + ? { statePool: poolKeyById.get(String(first.state_pool_id)) } + : {}), + perStack: first.value_scaling === 'per_state_unit', + ...(first.min_state_value === null || first.min_state_value === undefined ? {} : { minStacks: Number(first.min_state_value) }), + ...(first.max_state_value === null || first.max_state_value === undefined ? {} : { maxStacks: Number(first.max_state_value) }), + ...(first.description_slot === null || first.description_slot === undefined ? {} : { descriptionSlot: Number(first.description_slot) }), + variants: Object.fromEntries(variantKeys.map((variant) => { + const variantRow = variants.find((candidate) => candidate.variant_key === variant); + return [variant, { + value: Number(variantRow?.value ?? 0), + ...(variantRow?.value_expression_json + ? { expression: parseJson(variantRow.value_expression_json, Number(variantRow.value ?? 0)) } + : {}) + }]; + })) + }; + }); + return { + key: String(row.clause_key), + label: String(row.label), + event: String(row.event_kind) as ConditionalMechanicClauseDraft['event'], + ...(row.recipe_key ? { recipeKey: String(row.recipe_key) } : {}), + if: parseJson(row.conditions_json, { kind: 'true' }), + reducer: parseJson(row.reducer_json, { kind: 'none' }), + ...(row.weight_expression_json + ? { weight: parseJson(row.weight_expression_json, 1) } + : {}), + ...(row.cooldown_seconds === null || row.cooldown_seconds === undefined + ? {} + : { cooldownSeconds: Number(row.cooldown_seconds) }), + mutations: parseJson(row.mutations_json, []), + outcomes + }; + }); + return { + version: 1, + simulationReadiness: String(rule.simulation_readiness ?? 'captured_only') as ConditionalMechanicDraftV1['simulationReadiness'], + scenarioInputs: parseJson(rule.scenario_inputs_json, []), + pools, + clauses + }; +} + +export async function loadConditionalRuleDraft( + database: Database, + sourceType: ConditionalSourceType, + sourceId: string, + variantKeys: readonly string[] +) { + const rule = await (await database.prepare( + 'SELECT * FROM conditional_effect_rules WHERE source_type=? AND source_id=?' + )).get(sourceType, sourceId); + if (!rule) return null; + const [pools, clauses, outcomes] = await Promise.all([ + (await database.prepare('SELECT * FROM conditional_effect_state_pools WHERE rule_id=? ORDER BY sort_order')).all(rule.id), + (await database.prepare('SELECT * FROM conditional_effect_clauses WHERE rule_id=? ORDER BY sort_order')).all(rule.id), + (await database.prepare('SELECT * FROM conditional_effect_outcomes WHERE rule_id=? ORDER BY sort_order,variant_key')).all(rule.id) + ]); + return conditionalRuleDraftFromRows(rule, pools, clauses, outcomes, variantKeys); +} + +/** + * Replaces one source-owned mechanic program inside the caller's active transaction. + * Every ISAC source mutation must include this write in the same transaction as its + * source record and append-only audit event. + */ +export async function saveConditionalRuleInTransaction( + database: Database, + sourceType: ConditionalSourceType, + sourceId: string, + draft: ConditionalMechanicDraftV1 +) { + if (!sourceTypes.includes(sourceType)) throw new Error('Unsupported conditional source.'); + const existing = await (await database.prepare( + 'SELECT id,created_at FROM conditional_effect_rules WHERE source_type=? AND source_id=?' + )).get(sourceType, sourceId); + const now = new Date().toISOString(); + const ruleId = String(existing?.id ?? randomUUID()); + await (await database.prepare(`INSERT INTO conditional_effect_rules + (id,source_type,source_id,configured,script_version,simulation_readiness, + scenario_inputs_json,created_at,updated_at) + VALUES(?,?,?,1,1,?,?,?,?) + ON CONFLICT(source_type,source_id) DO UPDATE SET + configured=1, + script_version=1, + simulation_readiness=excluded.simulation_readiness, + scenario_inputs_json=excluded.scenario_inputs_json, + updated_at=excluded.updated_at`)).run( + ruleId, + sourceType, + sourceId, + draft.simulationReadiness, + JSON.stringify(draft.scenarioInputs), + String(existing?.created_at ?? now), + now + ); + await (await database.prepare('DELETE FROM conditional_effect_outcomes WHERE rule_id=?')).run(ruleId); + await (await database.prepare('DELETE FROM conditional_effect_clauses WHERE rule_id=?')).run(ruleId); + await (await database.prepare('DELETE FROM conditional_effect_state_pools WHERE rule_id=?')).run(ruleId); + + const poolIds = new Map(); + for (const [index, pool] of draft.pools.entries()) { + const poolId = randomUUID(); + poolIds.set(pool.key, poolId); + await (await database.prepare(`INSERT INTO conditional_effect_state_pools + (id,rule_id,state_key,label,state_kind,state_scope,initial_value,minimum_value, + maximum_value,best_case_value,duration_seconds,expiry_policy,refresh_policy, + parameters_json,sort_order,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run( + poolId, + ruleId, + pool.key, + pool.label, + pool.stateKind, + pool.scope, + pool.initial, + pool.min ?? null, + pool.max ?? Math.max(1, pool.bestCaseValue ?? pool.initial ?? 1), + pool.bestCaseValue ?? null, + pool.expiresAfterSeconds ?? null, + 'clear_all', + 'refresh', + '{}', + index + 1, + now, + now + ); + } + + let outcomeSortOrder = 0; + for (const [clauseIndex, clause] of draft.clauses.entries()) { + const clauseId = randomUUID(); + await (await database.prepare(`INSERT INTO conditional_effect_clauses + (id,rule_id,clause_key,label,event_kind,recipe_key,recipe_parameters_json, + conditions_json,reducer_json,weight_expression_json,emission_kind,state_pool_id, + mutations_json,cooldown_seconds,output_branch,sort_order,active,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run( + clauseId, + ruleId, + clause.key, + clause.label || clause.key, + clause.event, + clause.recipeKey ?? null, + '{}', + JSON.stringify(clause.if ?? { kind: 'true' }), + JSON.stringify(clause.reducer), + clause.weight === undefined ? null : JSON.stringify(clause.weight), + 'each_match', + null, + JSON.stringify(clause.mutations), + clause.cooldownSeconds ?? null, + 'active', + clauseIndex + 1, + 1, + now, + now + ); + for (const [outcomeIndex, outcome] of clause.outcomes.entries()) { + for (const [variantKey, variant] of Object.entries(outcome.variants)) { + outcomeSortOrder += 1; + await (await database.prepare(`INSERT INTO conditional_effect_outcomes + (id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key, + application_kind,effect_scope,stat_attribute_id,value,value_expression_json, + value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing, + resource_kind,min_state_value,max_state_value,description_slot,sort_order, + created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run( + randomUUID(), + ruleId, + clauseId, + outcome.statePool ? poolIds.get(outcome.statePool) ?? null : null, + outcome.key, + variantKey, + outcome.label, + applicationKind(outcome), + outcome.scope, + outcome.attributeId ?? null, + variant.value, + variant.expression === undefined ? null : JSON.stringify(variant.expression), + outcome.basis, + outcome.perStack === false ? 'flat' : 'per_state_unit', + null, + outcome.kind === 'direct_damage' + ? outcome.basis === 'weapon_base_percent' ? 'weapon_base_percent' : 'flat' + : null, + outcome.phase, + outcome.combatTiming ?? 'current', + outcome.resource ?? null, + outcome.minStacks ?? null, + outcome.maxStacks ?? null, + outcome.descriptionSlot ?? null, + outcomeSortOrder, + now, + now + ); + } + } + } + return ruleId; +} + +export async function conditionalRuleSnapshot( + database: Database, + sourceType: ConditionalSourceType, + sourceId: string +) { + const rule = await (await database.prepare( + 'SELECT * FROM conditional_effect_rules WHERE source_type=? AND source_id=?' + )).get(sourceType, sourceId); + if (!rule) return null; + return { + rule, + statePools: await (await database.prepare( + 'SELECT * FROM conditional_effect_state_pools WHERE rule_id=? ORDER BY sort_order' + )).all(rule.id), + clauses: await (await database.prepare( + 'SELECT * FROM conditional_effect_clauses WHERE rule_id=? ORDER BY sort_order' + )).all(rule.id), + outcomes: await (await database.prepare( + 'SELECT * FROM conditional_effect_outcomes WHERE rule_id=? ORDER BY variant_key,outcome_key,sort_order' + )).all(rule.id) + }; +} diff --git a/src/lib/server/data-moderation.ts b/src/lib/server/data-moderation.ts index 088e750..c4d7b90 100644 --- a/src/lib/server/data-moderation.ts +++ b/src/lib/server/data-moderation.ts @@ -6,7 +6,7 @@ import { recordIsacAudit } from '$lib/server/audit'; import { getContributorMode } from '$lib/server/contributor-mode'; import { notifyOversightReview } from '$lib/server/oversight-notifications'; -export const DATA_MODULES = ['configuration', 'calculations', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips', 'specializations'] as const; +export const DATA_MODULES = ['configuration', 'calculations', 'specializations', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips'] as const; export type DataModule = typeof DATA_MODULES[number]; const proposalResultKeys: Partial>> = { @@ -24,10 +24,7 @@ const proposalResultKeys: Partial { + const rule = conditionalRules.find((item) => item.source_type === sourceType && item.source_id === sourceId); + return rule ? { + rule, + statePools: conditionalPools.filter((pool) => pool.rule_id === rule.id), + clauses: conditionalClauses.filter((clause) => clause.rule_id === rule.id), + outcomes: conditionalOutcomes.filter((outcome) => outcome.rule_id === rule.id), + triggers: [] + } : null; + }; const configuredTalents = talents.map((talent) => ({ ...talent, template: talentTemplates.find((item) => item.talent_id === talent.id)?.template ?? '', @@ -85,7 +109,8 @@ export async function loadSharedBuildCombatCatalog(database: Database) { .map((tier) => ({ ...tier, effects: talentEffects.filter((effect) => effect.talent_tier_id === tier.id) - })) + })), + rule: hydrateRule('talent', talent.id) })); return { @@ -109,7 +134,8 @@ export async function loadSharedBuildCombatCatalog(database: Database) { talent: talent ? { ...talent, - effects: chipTalentEffects.filter((effect) => effect.talent_id === talent.id) + effects: chipTalentEffects.filter((effect) => effect.talent_id === talent.id), + rule: hydrateRule('skill_chip_talent', talent.id) } : null }; @@ -130,7 +156,8 @@ export async function loadSharedBuildCombatCatalog(database: Database) { talent: talent ? { ...talent, - effects: protocolTalentEffects.filter((effect) => effect.talent_id === talent.id) + effects: protocolTalentEffects.filter((effect) => effect.talent_id === talent.id), + rule: hydrateRule('os_protocol_talent', talent.id) } : null }; @@ -143,7 +170,13 @@ export async function loadSharedBuildCombatCatalog(database: Database) { const abilities = focusAbilities .filter((link) => link.focus_id === focus.id) .sort((left, right) => Number(left.ability_slot) - Number(right.ability_slot)) - .map((link) => specializationAbilities.find((ability) => ability.id === link.ability_id)) + .map((link) => { + const ability = specializationAbilities.find((entry) => entry.id === link.ability_id); + return ability ? { + ...ability, + attack: specializationAbilityAttacks.find((attack) => attack.ability_id === ability.id) ?? null + } : null; + }) .filter(Boolean); const nodes = specializationNodes .filter((node) => node.focus_id === focus.id) @@ -171,7 +204,8 @@ export async function loadSharedBuildCombatCatalog(database: Database) { ), modifiers: specializationCombatModifiers.filter( (modifier) => modifier.rank_combat_effect_id === effect.id - ) + ), + rule: hydrateRule('specialization_tree_rank', effect.id) })) })) })); @@ -181,7 +215,8 @@ export async function loadSharedBuildCombatCatalog(database: Database) { calculation: { revision: activeCalculationRevision ?? null, stages: calculationStages, - profiles: calculationProfiles + profiles: calculationProfiles, + skillProfiles: skillCalculationProfiles } }; } diff --git a/src/lib/toast.ts b/src/lib/toast.ts index fc0405d..0784b0f 100644 --- a/src/lib/toast.ts +++ b/src/lib/toast.ts @@ -89,7 +89,6 @@ export function actionFeedback( const actionMessages: Record = { save: 'Changes saved', saveTalent: 'Talent saved', - saveTrigger: 'Trigger saved', savePreferences: 'Preferences saved', saveCoreRanges: 'Configuration saved', saveProfile: 'Profile saved', diff --git a/src/routes/builder/+page.server.ts b/src/routes/builder/+page.server.ts index 6575e88..4dce68a 100644 --- a/src/routes/builder/+page.server.ts +++ b/src/routes/builder/+page.server.ts @@ -34,13 +34,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const [ attributes, gear, gearBrands, gearTalents, gearAugments, gearAugmentCosts, brands, brandEffects, weapons, weaponRanges, weaponTalentOptions, weaponBonuses, - talents, talentTiers, talentEffects, talentTemplates, + talents, talentTiers, talentEffects, talentTemplates, conditionalRules, conditionalPools, conditionalClauses, conditionalOutcomes, chipSets, chipTwoPiece, chipPrimary, chipSecondary, chipTalents, chipTalentEffects, protocols, protocolPrimary, protocolProfiles, protocolSecondary, protocolTalents, protocolTalentEffects, coreRanges, - icons, specializations, specializationFocuses, specializationAbilities, focusAbilities, + icons, specializations, specializationFocuses, specializationAbilities, specializationAbilityAttacks, focusAbilities, specializationNodes, specializationRanks, specializationRankEffects, specializationRankCosts, specializationCombatEffects, specializationRankCombatEffects, specializationCombatModifiers, - progression, rankProfiles, activeCalculationRevision, calculationStages, calculationProfiles + progression, rankProfiles, activeCalculationRevision, calculationStages, calculationProfiles, skillCalculationProfiles ] = await Promise.all([ (await database.prepare('SELECT * FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), (await database.prepare('SELECT * FROM gear WHERE active=1 ORDER BY slot,name COLLATE NOCASE')).all(), @@ -58,6 +58,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => { (await database.prepare('SELECT * FROM talent_tiers ORDER BY tier')).all(), (await database.prepare('SELECT * FROM talent_tier_effects')).all(), (await database.prepare('SELECT * FROM talent_description_templates')).all(), + (await database.prepare('SELECT * FROM conditional_effect_rules WHERE configured=1')).all(), + (await database.prepare('SELECT * FROM conditional_effect_state_pools ORDER BY rule_id,sort_order')).all(), + (await database.prepare('SELECT * FROM conditional_effect_clauses WHERE active=1 ORDER BY rule_id,sort_order')).all(), + (await database.prepare('SELECT * FROM conditional_effect_outcomes ORDER BY rule_id,variant_key,outcome_key,sort_order')).all(), (await database.prepare('SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), (await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')).all(), (await database.prepare('SELECT * FROM skill_chip_primary_options')).all(), @@ -75,6 +79,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { (await database.prepare('SELECT * FROM specializations WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), (await database.prepare('SELECT * FROM specialization_focuses WHERE active=1 ORDER BY specialization_id,display_order,name COLLATE NOCASE')).all(), (await database.prepare('SELECT * FROM specialization_abilities WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE')).all(), + (await database.prepare('SELECT * FROM specialization_ability_attacks')).all(), (await database.prepare('SELECT * FROM specialization_focus_abilities ORDER BY focus_id,ability_slot')).all(), (await database.prepare('SELECT * FROM specialization_tree_nodes WHERE active=1 ORDER BY focus_id,row_number,column_number')).all(), (await database.prepare('SELECT * FROM specialization_tree_node_ranks ORDER BY node_id,rank')).all(), @@ -90,6 +95,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => { JOIN calculation_revisions revision ON revision.id=stage.revision_id WHERE revision.status='active' ORDER BY stage.display_order`)).all(), (await database.prepare(`SELECT profile.* FROM attribute_calculation_profiles profile + JOIN calculation_revisions revision ON revision.id=profile.revision_id + WHERE revision.status='active' ORDER BY profile.stage_key,profile.stat_attribute_id`)).all(), + (await database.prepare(`SELECT profile.* FROM skill_attribute_calculation_profiles profile JOIN calculation_revisions revision ON revision.id=profile.revision_id WHERE revision.status='active' ORDER BY profile.stage_key,profile.stat_attribute_id`)).all() ]); @@ -97,6 +105,16 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const icon = icons.find((item) => item.entity_type === type && item.entity_id === id); return icon ? gameDataIconUrl(type, String(id), icon.updated_at) : ''; }; + const hydrateRule = (sourceType: string, sourceId: unknown) => { + const rule = conditionalRules.find((item) => item.source_type === sourceType && item.source_id === sourceId); + return rule ? { + rule, + statePools: conditionalPools.filter((pool) => pool.rule_id === rule.id), + clauses: conditionalClauses.filter((clause) => clause.rule_id === rule.id), + outcomes: conditionalOutcomes.filter((outcome) => outcome.rule_id === rule.id), + triggers: [] + } : null; + }; const configuredTalents = talents.map((talent) => ({ ...talent, iconUrl: iconUrl('talent', talent.id), @@ -104,7 +122,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => { tiers: talentTiers.filter((tier) => tier.talent_id === talent.id).map((tier) => ({ ...tier, effects: talentEffects.filter((effect) => effect.talent_tier_id === tier.id) - })) + })), + rule: hydrateRule('talent', talent.id) })); return { viewer: viewer ? { id: viewer.id, displayName: viewer.displayName } : null, @@ -141,7 +160,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => { twoPiece: chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ?? null, primaryStatIds: chipPrimary.filter((item) => item.skill_chip_set_id === set.id).map((item) => String(item.stat_attribute_id)), secondaryStatIds: chipSecondary.filter((item) => item.skill_chip_set_id === set.id).map((item) => String(item.stat_attribute_id)), - talent: talent ? { ...talent, effects: chipTalentEffects.filter((effect) => effect.talent_id === talent.id) } : null + talent: talent ? { + ...talent, + effects: chipTalentEffects.filter((effect) => effect.talent_id === talent.id), + rule: hydrateRule('skill_chip_talent', talent.id) + } : null }; }), protocols: protocols.map((protocol) => { @@ -152,7 +175,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => { iconUrl: iconUrl('os_protocol', protocol.id), primaryEffect: protocolPrimary.find((item) => item.protocol_id === protocol.id) ?? null, secondaryEffects: profile ? protocolSecondary.filter((effect) => effect.profile_id === profile.id) : [], - talent: talent ? { ...talent, effects: protocolTalentEffects.filter((effect) => effect.talent_id === talent.id) } : null + talent: talent ? { + ...talent, + effects: protocolTalentEffects.filter((effect) => effect.talent_id === talent.id), + rule: hydrateRule('os_protocol_talent', talent.id) + } : null }; }), specializations: specializations.map((specialization) => ({ @@ -166,7 +193,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => { return ability ? { ...ability, slot: Number(link.ability_slot), - iconUrl: iconUrl('specialization_ability', ability.id) + iconUrl: iconUrl('specialization_ability', ability.id), + attack: specializationAbilityAttacks.find((attack) => attack.ability_id === ability.id) ?? null } : null; }).filter(Boolean); const nodes = specializationNodes.filter((node) => node.focus_id === focus.id).map((node) => ({ @@ -181,7 +209,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => { .map((effect) => ({ ...effect, definition: specializationCombatEffects.find((definition) => definition.id === effect.combat_effect_id), - modifiers: specializationCombatModifiers.filter((modifier) => modifier.rank_combat_effect_id === effect.id) + modifiers: specializationCombatModifiers.filter((modifier) => modifier.rank_combat_effect_id === effect.id), + rule: hydrateRule('specialization_tree_rank', effect.id) })) })) })); @@ -196,7 +225,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => { calculation: { revision: activeCalculationRevision, stages: calculationStages, - profiles: calculationProfiles + profiles: calculationProfiles, + skillProfiles: skillCalculationProfiles } }; }; diff --git a/src/routes/builder/+page.svelte b/src/routes/builder/+page.svelte index ede9720..6e7bbc1 100644 --- a/src/routes/builder/+page.svelte +++ b/src/routes/builder/+page.svelte @@ -11,8 +11,10 @@ import CombatAssumptions from '$lib/components/builder/CombatAssumptions.svelte'; import ConditionalEffectCard from '$lib/components/builder/ConditionalEffectCard.svelte'; import WeaponCalculationResult from '$lib/components/builder/WeaponCalculationResult.svelte'; - import { evaluateLoadoutCombat, summarizeLoadout } from '$lib/builder/loadout-combat'; + import SkillCalculationResult from '$lib/components/builder/SkillCalculationResult.svelte'; + import { evaluateLoadoutCombat, evaluateLoadoutSkillCombat, summarizeLoadout } from '$lib/builder/loadout-combat'; import { toast } from '$lib/toast'; + import { MECHANIC_EVENT_REGISTRY, type MechanicEventKind } from '$lib/calculations/mechanic'; import { blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, weaponBaseRange, type LoadoutDraft, type Roll @@ -52,7 +54,9 @@ return selectedRank ? [{ node, rank: node.ranks.find((entry: any) => Number(entry.rank) === selectedRank) }] : []; }) ?? []); const selectedCombatEffects = $derived(selectedNodeRanks.flatMap(({ node, rank }: any) => - (rank?.combatEffects ?? []).map((effect: any) => ({ ...effect, nodeName: node.name })) + (rank?.combatEffects ?? []) + .filter((effect: any) => Number(effect.rule?.configured) === 1) + .map((effect: any) => ({ ...effect, nodeName: node.name })) )); const talent = (id: string) => data.talents.find((item) => item.id === id); const brand = (id: string) => data.brands.find((item) => item.id === id); @@ -62,19 +66,69 @@ const unit = attribute(statId)?.unit ?? ''; return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`; }; + const jsonObject = (value: unknown) => { + if (value && typeof value === 'object' && !Array.isArray(value)) return value as Record; + if (typeof value !== 'string') return {}; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : {}; + } catch { + return {}; + } + }; + const mechanicScenarioInputs = (rule: any) => { + const raw = rule?.rule?.scenario_inputs_json; + if (Array.isArray(raw)) return raw; + if (typeof raw !== 'string') return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + }; const combatEffectDescription = (effect: any) => { const template = String(effect.definition?.description_template ?? effect.definition?.name ?? 'Conditional effect'); + const rule = effect.rule; + const outcomes = (rule?.outcomes ?? []).filter((outcome: any) => outcome.variant_key === 'base'); + const firstReducer = jsonObject(rule?.clauses?.[0]?.reducer_json); + const primaryPool = rule?.statePools?.[0]; return template - .replaceAll('{triggerCount}', String(effect.trigger_count)) - .replaceAll('{triggerWindow}', `${effect.trigger_window_seconds}s`) - .replaceAll('{duration}', String(effect.duration_seconds)) - .replaceAll('{maxStacks}', String(effect.max_stacks)) + .replaceAll('{triggerCount}', String(firstReducer.threshold ?? '')) + .replaceAll('{triggerWindow}', firstReducer.windowSeconds ? `${firstReducer.windowSeconds}s` : '') + .replaceAll('{duration}', String(primaryPool?.duration_seconds ?? 0)) + .replaceAll('{maxStacks}', String(primaryPool?.maximum_value ?? 1)) .replaceAll('{effectName}', String(effect.definition?.name ?? 'Effect')) .replace(/\{(\d+)\}/g, (placeholder: string, raw: string) => { - const modifier = effect.modifiers[Number(raw) - 1]; - return modifier ? unitValue(modifier.value_per_stack, modifier.stat_attribute_id) : placeholder; + const outcome = outcomes[Number(raw) - 1]; + return outcome?.stat_attribute_id + ? unitValue(outcome.value, outcome.stat_attribute_id) + : placeholder; }); }; + const conditionalFacts = (rule: any) => { + const clauses = rule?.clauses ?? []; + const eventLabels = [...new Set(clauses.map((clause: any) => { + const event = String(clause.event_kind ?? '') as MechanicEventKind; + return MECHANIC_EVENT_REGISTRY[event]?.label; + }).filter(Boolean))]; + const readiness = String(rule?.rule?.simulation_readiness ?? 'captured_only') + .replaceAll('_', ' ') + .toUpperCase(); + const cooldowns = clauses + .map((clause: any) => Number(clause.cooldown_seconds ?? 0)) + .filter((value: number) => value > 0); + const poolMaximum = Math.max(0, ...(rule?.statePools ?? []) + .map((pool: any) => Number(pool.maximum_value ?? 0))); + return [ + `${clauses.length} BRANCH${clauses.length === 1 ? '' : 'ES'}`, + eventLabels.length ? eventLabels.join(' · ').toUpperCase() : 'NO EVENT BRANCHES', + poolMaximum > 1 ? `MAX ${poolMaximum} STACKS` : readiness, + cooldowns.length ? `${Math.max(...cooldowns)}s COOLDOWN` : 'NO COOLDOWN' + ]; + }; const scaledRange = (minimum: unknown, maximum: unknown, level: unknown, referenceLevel: unknown, source: 'gear' | 'weapon') => levelScaledRange(minimum, maximum, level, referenceLevel ?? 50, source); const gearBaseRange = (item: any, key: 'health' | 'armor', level: number) => @@ -159,15 +213,23 @@ draft.specialization = { ...draft.specialization, focusId, - nodeRanks: {}, - effectStates: {} + nodeRanks: {} }; active = 'specialization:focus'; }; const toggleTalent = (key: string) => { - draft.activeTalents = draft.activeTalents.includes(key) - ? draft.activeTalents.filter((item) => item !== key) - : [...draft.activeTalents, key]; + const current = draft.conditionalEffects[key]?.mode ?? 'off'; + draft.conditionalEffects = { + ...draft.conditionalEffects, + [key]: { mode: current === 'off' ? 'peak' : 'off' } + }; + }; + const setSkillActivation = (abilityId: string, patch: Record) => { + const current = draft.skillActivations[abilityId] ?? { mode: 'off', targets: 1 }; + draft.skillActivations = { + ...draft.skillActivations, + [abilityId]: { ...current, ...patch } + }; }; const talentTier = (talentId: string, tier: number) => { const item = talent(talentId); @@ -289,19 +351,58 @@ return `Talent Cooldown: ${value} ${Number(value) === 1 ? 'second' : 'seconds'}`; }; const summary = $derived(summarizeLoadout(draft, data)); - const combatResults = $derived(evaluateLoadoutCombat(draft, data, summary)); + const modeledTalents = $derived(summary.equippedTalents.filter( + (equipped: any) => Number(equipped.rule?.rule?.configured) === 1 + && (equipped.rule?.clauses?.length ?? 0) > 0 + )); + const calculationDraft = $derived.by(() => { + if (draft.calculationMode === 'custom') return draft; + const effectKeys = [ + ...selectedCombatEffects.map((effect: any) => String(effect.id)), + ...modeledTalents.map((effect: any) => String(effect.key)) + ]; + const conditionalEffects = { + ...draft.conditionalEffects, + ...Object.fromEntries(effectKeys.map((key) => [ + key, + { ...draft.conditionalEffects[key], mode: 'peak' as const } + ])) + }; + const skillActivations = { ...draft.skillActivations }; + for (const ability of selectedFocus?.abilities ?? []) { + skillActivations[ability.id] = { + mode: 'on_cooldown', + targets: Math.max(1, Number(ability.attack?.target_cap ?? 1)) + }; + } + return { ...draft, conditionalEffects, skillActivations }; + }); + const combatResults = $derived(evaluateLoadoutCombat(calculationDraft, data, summary)); + const skillCombatResult = $derived(evaluateLoadoutSkillCombat(calculationDraft, data, summary)); + const skillAbilityNames = $derived(Object.fromEntries( + (selectedFocus?.abilities ?? []).map((ability: any) => [String(ability.id), String(ability.name)]) + )); $effect(() => { - const missing = selectedCombatEffects.filter((effect: any) => !draft.specialization.effectStates[effect.id]); - if (!missing.length) return; - draft.specialization.effectStates = { - ...draft.specialization.effectStates, - ...Object.fromEntries(missing.map((effect: any) => [effect.id, { mode: 'off' }])) + const sources = [ + ...selectedCombatEffects.map((effect: any) => ({ key: String(effect.id) })), + ...modeledTalents + ]; + const missing = sources.filter((source: any) => !draft.conditionalEffects[source.key]); + if (missing.length) draft.conditionalEffects = { + ...draft.conditionalEffects, + ...Object.fromEntries(missing.map((effect: any) => [effect.key, { mode: 'off' }])) + }; + const missingAbilities = (selectedFocus?.abilities ?? []).filter((ability: any) => !draft.skillActivations[ability.id]); + if (missingAbilities.length) draft.skillActivations = { + ...draft.skillActivations, + ...Object.fromEntries(missingAbilities.map((ability: any) => [ability.id, { mode: 'off', targets: 1 }])) }; }); onMount(() => { if (data.initialBuild) return; - const stored = localStorage.getItem('shd-build-loadout-draft-v2'); + localStorage.removeItem('shd-build-loadout-draft-v3'); + const stored = localStorage.getItem('shd-build-loadout-draft-v4'); if (!stored) return; try { const parsed = parseLoadoutDraft(JSON.parse(stored)); @@ -309,9 +410,9 @@ draft = parsed; if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = ''; saved = true; - } else localStorage.removeItem('shd-build-loadout-draft-v2'); + } else localStorage.removeItem('shd-build-loadout-draft-v4'); } catch { - localStorage.removeItem('shd-build-loadout-draft-v2'); + localStorage.removeItem('shd-build-loadout-draft-v4'); } }); $effect(() => { @@ -327,7 +428,7 @@ : 'Build published to the SHD archive.'; }); function saveDraft() { - localStorage.setItem('shd-build-loadout-draft-v2', JSON.stringify(draft)); + localStorage.setItem('shd-build-loadout-draft-v4', JSON.stringify(draft)); saved = true; toast.success('Draft saved locally', 'This loadout is available on this device.'); } @@ -365,7 +466,7 @@ {#if persistedSlug && persistedVisibility !== 'private'}VIEW SHARE PAGE →{/if} {/if} -
LIVE CALCULATION18 EQUIPMENT POSITIONS{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS
+
LIVE CALCULATION{draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : 'CUSTOM SCENARIO'}18 EQUIPMENT POSITIONS{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS
{#if message}

{message}

{/if} {#if form?.buildError}{/if} @@ -394,6 +495,20 @@ {/each} + {#if draft.calculationMode === 'custom'}
+
SKILL ACTIVATION PROFILEALL ENABLED ABILITIES CONTRIBUTE TO COMBINED SKILL DPS
{selectedFocus.abilities.filter((ability: any) => (draft.skillActivations[ability.id]?.mode ?? 'off') !== 'off').length} ENABLED
+ {#each selectedFocus.abilities as ability} + {@const activation = draft.skillActivations[ability.id] ?? { mode: 'off', targets: 1 }} +
+
{ability.name}{ability.attack ? `${ability.attack.delivery.toUpperCase()} · ${ability.attack.damage_type}` : 'ATTACK PROFILE NOT CONFIGURED'}
+ setSkillActivation(ability.id,{mode:values[0]})}/> +
+ {#if activation.mode === 'custom_per_minute'} setSkillActivation(ability.id,{usesPerMinute:value})}/>{/if} + setSkillActivation(ability.id,{targets:value})}/> +
+
+ {/each} +
{/if}
POINTS {selectedSpecializationPoints} / {data.specializationProgression.availablePoints}CREDITS {selectedSpecializationCosts.credits.toLocaleString()}{#each selectedSpecializationCosts.resources as resource}{String(resource[0]).replace('resource-electronics-','').replaceAll('-',' ').toUpperCase()} × {resource[1]}{/each}
FOCUS UPGRADE TREEPREVIOUS ROWS REQUIRE EVERY AVAILABLE RANK 1 + 2 BEFORE ADVANCING
{selectedFocus.nodes.length} NODES
@@ -405,9 +520,40 @@
{/each} - + {#if selectedCombatEffects.length} -
CONDITIONAL EFFECT PROFILESCHOOSE HOW EACH UNLOCKED EFFECT CONTRIBUTES TO DPS
{selectedCombatEffects.length} EFFECTS
{#each selectedCombatEffects as effect} `${unitValue(modifier.value_per_stack,modifier.stat_attribute_id)} ${attribute(modifier.stat_attribute_id)?.name ?? 'ATTRIBUTE'} PER STACK`)]} appliedState={draft.specialization.effectStates[effect.id]?.mode === 'peak' ? `APPLIED: ${effect.max_stacks}/${effect.max_stacks}` : draft.specialization.effectStates[effect.id]?.mode === 'encounter' ? `APPLIED: 0→${effect.max_stacks}/${effect.max_stacks}` : 'NOT APPLIED'} bind:effectState={draft.specialization.effectStates[effect.id]}/>{/each}
+
+
CONDITIONAL EFFECT PROFILESCHOOSE HOW EACH UNLOCKED EFFECT CONTRIBUTES TO DPS
{selectedCombatEffects.length} EFFECTS
+ {#each selectedCombatEffects as effect} + + {/each} +
+ {/if} + {#if modeledTalents.length} +
+
EQUIPPED TALENT MODELINGMODEL EACH MECHANIC PROGRAM OR EXCLUDE IT FROM CALCULATIONS
{modeledTalents.length} TALENTS
+ {#each modeledTalents as equipped} + + {/each} +
{/if} {/if} {:else if activeParts[0] === 'gear'} @@ -478,12 +624,13 @@

LIVE SUMMARY

AGENT STATS

{summary.stats.length}
{#each ['Firepower','Toughness','Engineering'] as name}{@const stat = summary.stats.find((entry) => attribute(entry.id)?.name === name)}
{name}{stat ? unitValue(stat.value,stat.id) : '0'}
{/each}
{#each combatResults as result}{/each}
+
COMBINED ATTRIBUTES{summary.stats.length}
{#each summary.stats as stat}
{attribute(stat.id)?.name}{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}{unitValue(stat.value,stat.id)}
{:else}

Equip and configure items to calculate attributes.

{/each}
ACTIVE SET BONUSES{summary.activeBonuses.length}
{#each summary.activeBonuses as bonus}
{bonus.name}{bonus.pieces}-PIECE · {bonus.count} EQUIPPED+{unitValue(bonus.value,bonus.statId)} {attribute(bonus.statId)?.name}
{:else}

Matching Brand Sets and Skill Chips activate bonuses here.

{/each}
-
EQUIPPED TALENTS{summary.equippedTalents.length}
{#each summary.equippedTalents as equipped}{:else}

Selected equipment talents appear here.

{/each}
+
EQUIPPED TALENTS{summary.equippedTalents.length}
{#each summary.equippedTalents as equipped}{:else}

Selected equipment talents appear here.

{/each}
-

Talent effects enter totals only while marked trigger active. Anonymous drafts remain on this device.

+

{draft.calculationMode === 'maximum' ? 'Maximum Potential applies every configured effect at best-case uptime. ' : 'Custom Scenario uses each mechanic program and the encounter settings saved with this loadout. '}Anonymous drafts remain on this device.

diff --git a/src/routes/database/[category]/+page.server.ts b/src/routes/database/[category]/+page.server.ts index d66cafa..a234717 100644 --- a/src/routes/database/[category]/+page.server.ts +++ b/src/routes/database/[category]/+page.server.ts @@ -6,6 +6,7 @@ import { getDatabase } from '$lib/server/database'; import { getPagination, getRegistrySearch } from '$lib/server/pagination'; import { error } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; +import { MECHANIC_EVENT_REGISTRY, type MechanicEventKind } from '$lib/calculations/mechanic'; const number = (value: unknown) => { const numeric = Number(value); @@ -18,6 +19,19 @@ const gearIcon = (slot: unknown) => String(slot).toLowerCase().replaceAll(' ', ' const weaponIcon = (category: unknown) => String(category).toLowerCase() as GameIconName; const iconUrl = (type: GameDataIconType, id: unknown, updatedAt: unknown) => updatedAt ? gameDataIconUrl(type, String(id), updatedAt) : ''; +const parsedObject = (value: unknown): Record => { + try { + const parsed = JSON.parse(String(value ?? '{}')); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : {}; + } catch { + return {}; + } +}; +const humanized = (value: unknown) => String(value ?? '') + .replaceAll('_', ' ') + .replace(/\b\w/g, (character) => character.toUpperCase()); export const load: PageServerLoad = async ({ params, url }) => { const category = databaseCategory(params.category); @@ -330,6 +344,21 @@ export const load: PageServerLoad = async ({ params, url }) => { JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id WHERE effects.brand_set_id IN (${rows.map(() => '?').join(',')}) ORDER BY effects.pieces_required`)).all(...rows.map((row) => row.id)) : []; + const talentMechanics = category.slug === 'talents' && rows.length + ? await (await database.prepare(`SELECT rules.source_id AS talent_id,rules.id AS rule_id, + clauses.id AS clause_id,clauses.label AS clause_label,clauses.event_kind, + clauses.conditions_json,clauses.reducer_json,clauses.cooldown_seconds, + outcomes.variant_key,outcomes.outcome_key,outcomes.application_kind,outcomes.effect_scope, + outcomes.value,outcomes.damage_basis,attributes.name AS attribute_name,attributes.unit AS attribute_unit, + outcomes.sort_order + FROM conditional_effect_rules rules + JOIN conditional_effect_clauses clauses ON clauses.rule_id=rules.id AND clauses.active=1 + LEFT JOIN conditional_effect_outcomes outcomes ON outcomes.clause_id=clauses.id + LEFT JOIN stat_attributes attributes ON attributes.id=outcomes.stat_attribute_id + WHERE rules.source_type='talent' AND rules.configured=1 + AND rules.source_id IN (${rows.map(() => '?').join(',')}) + ORDER BY rules.source_id,clauses.sort_order,outcomes.outcome_key,outcomes.sort_order`)).all(...rows.map((row) => row.id)) + : []; const items = rows.map((row) => { switch (category.slug) { @@ -491,6 +520,49 @@ export const load: PageServerLoad = async ({ params, url }) => { const isWeaponTalent = talentCategory === 'Weapon'; const normalizedCategory = talentCategory === 'Vest' ? 'Body Armor' : talentCategory; const slotLabel = isWeaponTalent && row.weapon_slot ? `WEAPON ${row.weapon_slot}` : normalizedCategory.toUpperCase(); + const mechanics = talentMechanics.filter((mechanic) => mechanic.talent_id === row.id); + const rule = mechanics[0]; + const groupedOutcomes = new Map(); + for (const outcome of mechanics.filter((mechanic) => mechanic.variant_key)) { + const key = [ + outcome.outcome_key, + outcome.application_kind, + outcome.effect_scope, + outcome.attribute_name ?? '', + outcome.damage_basis ?? '' + ].join('|'); + const current = groupedOutcomes.get(key) ?? { + outcomeKey: String(outcome.outcome_key), + application: String(outcome.application_kind), + scope: String(outcome.effect_scope), + attribute: String(outcome.attribute_name ?? ''), + unit: outcome.attribute_unit, + damageBasis: outcome.damage_basis, + values: {} + }; + current.values[String(outcome.variant_key)] = Number(outcome.value); + groupedOutcomes.set(key, current); + } + const outcomeSummary = [...groupedOutcomes.values()].map((outcome) => { + const subject = outcome.application === 'active_modifier' + ? outcome.attribute + : outcome.application === 'direct_damage' + ? `Direct Damage · ${outcome.damageBasis === 'weapon_base_percent' ? 'Weapon Base' : 'Flat'}` + : humanized(outcome.application); + const values = ['I', 'II'] + .filter((variant) => Number.isFinite(outcome.values[variant])) + .map((variant) => { + const raw = Number(outcome.values[variant]); + const prefix = outcome.application === 'active_modifier' && raw > 0 ? '+' : ''; + return `T${variant} ${prefix}${valueWithUnit(raw, outcome.unit)}`; + }) + .join(' · '); + return `${subject} · ${values}`; + }).join(' / '); + const activation = [...new Map(mechanics.map((mechanic) => [ + String(mechanic.clause_id), + `${mechanic.clause_label || humanized(mechanic.event_kind)} · ${MECHANIC_EVENT_REGISTRY[mechanic.event_kind as MechanicEventKind]?.label ?? humanized(mechanic.event_kind)}` + ])).values()].join(' / '); return { id: String(row.id), name: String(row.name), kicker: `${slotLabel} TALENT`, iconUrl: iconUrl('talent',row.id,row.icon_updated_at), @@ -507,7 +579,11 @@ export const load: PageServerLoad = async ({ params, url }) => { description: row.tier_i ? String(row.tier_i) : 'Description is not available.', secondaryDescription: row.tier_ii ? String(row.tier_ii) : 'Description is not available.', descriptionStyle: 'data' as const, - afterLines: row.cooldown_seconds ? [{ label: 'TALENT COOLDOWN', value: `${number(row.cooldown_seconds)} SECONDS` }] : [] + afterLines: [ + ...(activation ? [{ label: 'ACTIVATES WHEN', value: activation }] : []), + ...(outcomeSummary ? [{ label: 'CALCULATION EFFECTS', value: outcomeSummary }] : []), + ...(row.cooldown_seconds ? [{ label: 'TALENT COOLDOWN', value: `${number(row.cooldown_seconds)} SECONDS` }] : []) + ] }; } case 'brands': { diff --git a/src/routes/isac/calculations/+page.server.ts b/src/routes/isac/calculations/+page.server.ts index db76729..09203d6 100644 --- a/src/routes/isac/calculations/+page.server.ts +++ b/src/routes/isac/calculations/+page.server.ts @@ -14,6 +14,12 @@ const operators = new Set([ 'reload_speed', 'firing_cycle' ]); const curves = new Set(['none', 'power', 'smoothstep']); +const skillStages = new Set([ + 'engineering', 'flat_damage', 'skill_damage', 'final_damage', + 'critical_chance', 'critical_damage', 'multishot_chance', 'multishot_damage', + 'cooldown_recovery', 'duration' +]); +const skillOperators = new Set(['add', 'multiply']); type StageInput = { stageKey?: string; @@ -32,6 +38,12 @@ type ProfileInput = { curveParameter?: number | string | null; weaponCategory?: string; }; +type SkillProfileInput = { + statAttributeId?: string; + stageKey?: string; + operator?: string; + weight?: number | string; +}; const clean = (value: unknown) => String(value ?? '').normalize('NFKC').trim(); const optionalNumber = (value: unknown) => { @@ -54,6 +66,9 @@ async function revisionSnapshot(database: Awaited stages: await (await database.prepare('SELECT * FROM calculation_stages WHERE revision_id=? ORDER BY display_order')).all(id), profiles: await (await database.prepare( 'SELECT * FROM attribute_calculation_profiles WHERE revision_id=? ORDER BY stage_key,stat_attribute_id' + )).all(id), + skillProfiles: await (await database.prepare( + 'SELECT * FROM skill_attribute_calculation_profiles WHERE revision_id=? ORDER BY stage_key,stat_attribute_id' )).all(id) }; } @@ -86,10 +101,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => { `SELECT COUNT(*) AS count FROM calculation_calibration_cases calibration ${where}` )).get(...args))?.count ?? 0); const pagination = getPagination(url, total); - const [revisions, stages, profiles, attributes, calibrationCases, calibrationSummary] = await Promise.all([ + const [revisions, stages, profiles, skillProfiles, attributes, calibrationCases, calibrationSummary] = await Promise.all([ (await database.prepare('SELECT * FROM calculation_revisions ORDER BY version DESC')).all(), (await database.prepare('SELECT * FROM calculation_stages ORDER BY revision_id,display_order')).all(), (await database.prepare('SELECT * FROM attribute_calculation_profiles ORDER BY revision_id,stage_key,stat_attribute_id')).all(), + (await database.prepare('SELECT * FROM skill_attribute_calculation_profiles ORDER BY revision_id,stage_key,stat_attribute_id')).all(), (await database.prepare('SELECT id,name,unit,type,category FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), (await database.prepare(`SELECT calibration.*,COALESCE(profile.display_name,user.username,calibration.created_by,'UNKNOWN') AS creator_name FROM calculation_calibration_cases calibration @@ -107,7 +123,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => { revisions: revisions.map((revision) => ({ ...revision, stages: stages.filter((stage) => stage.revision_id === revision.id), - profiles: profiles.filter((profile) => profile.revision_id === revision.id) + profiles: profiles.filter((profile) => profile.revision_id === revision.id), + skillProfiles: skillProfiles.filter((profile) => profile.revision_id === revision.id) })), attributes, calibrationCases, @@ -142,6 +159,12 @@ export const actions: Actions = { curveParameter: optionalNumber(profile.curveParameter), weaponCategory: clean(profile.weaponCategory) || '*' })); + const skillProfileInputs = parseArray(form.get('skillProfilesJson')).map((profile) => ({ + statAttributeId: clean(profile.statAttributeId), + stageKey: clean(profile.stageKey), + operator: clean(profile.operator), + weight: Number(profile.weight) + })); if (name.length < 3 || name.length > 120) return fail(400, { revisionError: 'Revision names must be 3–120 characters.' }); if (stageInputs.length < 2 || stageInputs.length > 30 || new Set(stageInputs.map((stage) => stage.stageKey)).size !== stageInputs.length @@ -166,8 +189,23 @@ export const actions: Actions = { || (profile.softCap !== null && profile.hardCap !== null && profile.hardCap < profile.softCap) || !curves.has(profile.curveType) )) return fail(400, { revisionError: 'One or more Attribute calculation profiles are invalid.' }); + if (skillProfileInputs.length > 2000 + || new Set(skillProfileInputs.map((profile) => + `${profile.statAttributeId}\u0000${profile.stageKey}` + )).size !== skillProfileInputs.length + || skillProfileInputs.some((profile) => + !profile.statAttributeId || !skillStages.has(profile.stageKey) + || !skillOperators.has(profile.operator) + || !Number.isFinite(profile.weight) + || profile.weight < -100 || profile.weight > 100 + )) { + return fail(400, { revisionError: 'One or more Skill calculation profiles are invalid or duplicated.' }); + } const database = await getDatabase(); - const attributeIds = [...new Set(profileInputs.map((profile) => profile.statAttributeId))]; + const attributeIds = [...new Set([ + ...profileInputs.map((profile) => profile.statAttributeId), + ...skillProfileInputs.map((profile) => profile.statAttributeId) + ])]; if (attributeIds.length) { const count = Number((await (await database.prepare( `SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${attributeIds.map(() => '?').join(',')})` @@ -208,6 +246,15 @@ export const actions: Actions = { profile.softCap, profile.hardCap, profile.curveType, profile.curveParameter, profile.weaponCategory ); } + await (await database.prepare('DELETE FROM skill_attribute_calculation_profiles WHERE revision_id=?')).run(revisionId); + for (const profile of skillProfileInputs) { + await (await database.prepare(`INSERT INTO skill_attribute_calculation_profiles + (id,revision_id,stat_attribute_id,stage_key,operator,weight) + VALUES(?,?,?,?,?,?)`)).run( + randomUUID(), revisionId, profile.statAttributeId, profile.stageKey, + profile.operator, profile.weight + ); + } await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); @@ -383,6 +430,14 @@ export const actions: Actions = { profile.curve_type, profile.curve_parameter, profile.weapon_category ); } + for (const profile of activeSnapshot.skillProfiles) { + await (await database.prepare(`INSERT INTO skill_attribute_calculation_profiles + (id,revision_id,stat_attribute_id,stage_key,operator,weight,soft_cap,hard_cap,curve_type,curve_parameter) + VALUES(?,?,?,?,?,?,?,?,?,?)`)).run( + randomUUID(), revisionId, profile.stat_attribute_id, profile.stage_key, profile.operator, + profile.weight, profile.soft_cap, profile.hard_cap, profile.curve_type, profile.curve_parameter + ); + } await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); diff --git a/src/routes/isac/calculations/+page.svelte b/src/routes/isac/calculations/+page.svelte index 85a2d51..9ceea0d 100644 --- a/src/routes/isac/calculations/+page.svelte +++ b/src/routes/isac/calculations/+page.svelte @@ -5,7 +5,10 @@ import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte'; import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; + import MechanicLanguageHelp from '$lib/components/MechanicLanguageHelp.svelte'; + import PseudoScript from '$lib/components/PseudoScript.svelte'; import { createDraft } from '$lib/builder/loadout'; + import { formatWeaponCalculationScript } from '$lib/calculations/calculation-script'; let { data, form } = $props(); type StageEditor = { @@ -25,6 +28,12 @@ curveParameter: number | string; weaponCategory: string; }; + type SkillProfileEditor = { + statAttributeId: string; + stageKey: string; + operator: 'add' | 'multiply'; + weight: number | string; + }; type RevisionEditor = { id: string; basedOnRevisionId: string; @@ -32,6 +41,7 @@ notes: string; stages: StageEditor[]; profiles: ProfileEditor[]; + skillProfiles: SkillProfileEditor[]; }; type CalibrationEditor = { id: string; @@ -53,6 +63,14 @@ }; const canPublish = $derived(data.permissions?.includes('*') || data.permissions?.includes('calculation.publish')); const activeRevision = $derived(data.revisions.find((revision: any) => revision.status === 'active')); + const activeCalculationScript = $derived(activeRevision + ? formatWeaponCalculationScript({ + revision: activeRevision, + stages: activeRevision.stages, + profiles: activeRevision.profiles, + attributes: data.attributes + }) + : []); const stageItems = (revision: RevisionEditor) => revision.stages.map((stage) => ({ id: stage.stageKey, name: stage.label, @@ -65,6 +83,22 @@ ]; const stateOptions = [{ value: 'draft', label: 'DRAFT' }, { value: 'ready', label: 'READY' }]; const weaponOptions = [{ value: '0', label: 'PRIMARY' }, { value: '1', label: 'SECONDARY' }]; + const skillStageItems = [ + { id: 'engineering', name: 'Engineering', category: 'CORE ATTRIBUTE' }, + { id: 'flat_damage', name: 'Flat damage', category: 'DAMAGE' }, + { id: 'skill_damage', name: 'Skill damage', category: 'DAMAGE' }, + { id: 'final_damage', name: 'Final damage', category: 'DAMAGE' }, + { id: 'critical_chance', name: 'Critical chance', category: 'CRITICAL' }, + { id: 'critical_damage', name: 'Critical damage', category: 'CRITICAL' }, + { id: 'multishot_chance', name: 'Multishot chance', category: 'MULTISHOT' }, + { id: 'multishot_damage', name: 'Multishot damage', category: 'MULTISHOT' }, + { id: 'cooldown_recovery', name: 'Cooldown recovery', category: 'CADENCE' }, + { id: 'duration', name: 'Duration', category: 'CADENCE' } + ]; + const skillOperatorOptions = [ + { value: 'add', label: 'ADD' }, + { value: 'multiply', label: 'MULTIPLY' } + ]; const revisionFrom = (revision: any, edit = false): RevisionEditor => ({ id: edit ? String(revision?.id ?? '') : '', @@ -87,6 +121,12 @@ curveType: String(profile.curve_type), curveParameter: profile.curve_parameter ?? '', weaponCategory: String(profile.weapon_category ?? '*') + })), + skillProfiles: (revision?.skillProfiles ?? []).map((profile: any) => ({ + statAttributeId: String(profile.stat_attribute_id), + stageKey: String(profile.stage_key), + operator: String(profile.operator) as 'add' | 'multiply', + weight: Number(profile.weight) })) }); const blankCalibration = (): CalibrationEditor => ({ @@ -111,6 +151,7 @@ let publishingId = $state(''); let publishConfirmed = $state(false); let handledToken = $state(''); + let scriptExpanded = $state(false); function addProfile() { if (!revisionEditing) return; @@ -119,6 +160,15 @@ weight: 1, softCap: '', hardCap: '', curveType: 'none', curveParameter: '', weaponCategory: '*' }]; } + function addSkillProfile() { + if (!revisionEditing) return; + revisionEditing.skillProfiles = [...revisionEditing.skillProfiles, { + statAttributeId: '', + stageKey: 'skill_damage', + operator: 'add', + weight: 1 + }]; + } function moveStage(index: number, direction: -1 | 1) { if (!revisionEditing) return; const target = index + direction; @@ -150,10 +200,40 @@
ACTIVE REVISION{activeRevision ? `R${activeRevision.version}` : 'NONE'}{activeRevision?.name ?? 'NO ACTIVE RULES'}
-
ATTRIBUTE PROFILES{activeRevision?.profiles.length ?? 0}TYPED BINDINGS
+
WEAPON PROFILES{activeRevision?.profiles.length ?? 0}ATTRIBUTE BINDINGS
+
SKILL PROFILES{activeRevision?.skillProfiles.length ?? 0}ABILITY BINDINGS
READY CASES{Number(data.calibrationSummary?.ready ?? 0)}{Number(data.calibrationSummary?.predicted ?? 0)} WITH PREDICTIONS
+
+
+
+

GENERATED CALCULATION LANGUAGE

+

ACTIVE WEAPON DAMAGE SCRIPT

+ The script explains the ordered typed revision. Editors remain the source of calculation data. +
+
+ + +
+
+ {#if scriptExpanded} +
+ {#if activeCalculationScript.length} + + {:else} +

No active calculation revision is available.

+ {/if} +
+ {/if} +
+ {#if revisionEditing}

{revisionEditing.id ? 'MODIFY DRAFT REVISION' : 'NEW DRAFT REVISION'}

{revisionEditing.name || 'CALCULATION RULE SET'}

@@ -162,6 +242,7 @@ +
ORDERED CALCULATION STAGESBASE AND FIRING-CYCLE POSITIONS ARE LOCKED
{revisionEditing.stages.length} STAGES
{#each [...revisionEditing.stages].sort((left, right) => left.displayOrder - right.displayOrder) as stage, index} @@ -182,6 +263,18 @@ {/each}
+
SKILL ATTRIBUTE PROFILESABILITY DAMAGE, CRITICAL, MULTISHOT, AND CADENCE BINDINGS
+
{#each revisionEditing.skillProfiles as profile, index} +
+
SKILL PROFILE {String(index + 1).padStart(2, '0')}
+
ATTRIBUTE
+
SKILL CALCULATION STAGE
+
+ + +
+
+ {/each}
{#if form?.revisionError}

{form.revisionError}

{/if}
@@ -225,7 +318,7 @@
REVISION {revision.version}

{revision.name}

{revision.status.toUpperCase()}

{revision.notes || 'No revision notes.'}

-
{revision.stages.length} STAGES · {revision.profiles.length} ATTRIBUTE PROFILES
{#if revision.status === 'draft'}{#if canPublish}{/if}{/if}
+
{revision.stages.length} STAGES · {revision.profiles.length} WEAPON · {revision.skillProfiles.length} SKILL PROFILES
{#if revision.status === 'draft'}{#if canPublish}{/if}{/if}
{#if publishingId === revision.id}
ACTIVATE REVISION {revision.version}?Every saved and shared build will recalculate using these rules.
@@ -252,7 +345,7 @@
diff --git a/src/routes/isac/data-moderation/+page.server.ts b/src/routes/isac/data-moderation/+page.server.ts index ed7c227..0038126 100644 --- a/src/routes/isac/data-moderation/+page.server.ts +++ b/src/routes/isac/data-moderation/+page.server.ts @@ -8,9 +8,10 @@ import { getPagination, getRegistrySort } from '$lib/server/pagination'; import { recordIsacAudit } from '$lib/server/audit'; import type { Actions, PageServerLoad } from './$types'; import { createNotification } from '$lib/server/notifications'; +import { conditionalRuleSnapshot } from '$lib/server/conditional-effects'; const statuses = ['pending', 'approved', 'rejected', 'failed']; -const modules = ['configuration', 'calculations', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips', 'specializations']; +const modules = ['configuration', 'calculations', 'specializations', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips']; type SerializedEntry = | { name: string; kind: 'text'; value: string } | { name: string; kind: 'file'; value: string; fileName: string; type: string }; @@ -33,6 +34,9 @@ async function specializationProposalSnapshot(database: any, proposal: any) { if (proposal.action === 'saveAbility') { return { record: await (await database.prepare('SELECT * FROM specialization_abilities WHERE id=?')).get(id) ?? null, + attack: await (await database.prepare( + 'SELECT * FROM specialization_ability_attacks WHERE ability_id=?' + )).get(id) ?? null, effects: await (await database.prepare( 'SELECT * FROM specialization_ability_effects WHERE ability_id=? ORDER BY effect_order' )).all(id) @@ -90,12 +94,12 @@ async function skillChipProposalSnapshot(database: any, id: string) { 'SELECT stat_attribute_id FROM skill_chip_secondary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id' )).all(id), talent: talent ?? null, - talentTriggers: talent ? await (await database.prepare( - 'SELECT trigger_id FROM skill_chip_talent_triggers WHERE talent_id=? ORDER BY trigger_id' - )).all(talent.id) : [], talentEffects: talent ? await (await database.prepare( 'SELECT * FROM skill_chip_talent_effects WHERE talent_id=? ORDER BY sort_order' - )).all(talent.id) : [] + )).all(talent.id) : [], + conditional: talent + ? await conditionalRuleSnapshot(database, 'skill_chip_talent', String(talent.id)) + : null }; } @@ -107,6 +111,9 @@ async function calculationProposalSnapshot(database: any, proposal: any) { stages: await (await database.prepare('SELECT * FROM calculation_stages WHERE revision_id=? ORDER BY display_order')).all(id), profiles: await (await database.prepare( 'SELECT * FROM attribute_calculation_profiles WHERE revision_id=? ORDER BY stage_key,stat_attribute_id' + )).all(id), + skillProfiles: await (await database.prepare( + 'SELECT * FROM skill_attribute_calculation_profiles WHERE revision_id=? ORDER BY stage_key,stat_attribute_id' )).all(id) }; if (proposal.action === 'saveCalibration') return { diff --git a/src/routes/isac/mechanic-reference/+page.server.ts b/src/routes/isac/mechanic-reference/+page.server.ts new file mode 100644 index 0000000..0076126 --- /dev/null +++ b/src/routes/isac/mechanic-reference/+page.server.ts @@ -0,0 +1,24 @@ +import type { PageServerLoad } from './$types'; +import { requireDataView } from '$lib/server/auth/authorization'; +import { getSessionUser } from '$lib/server/auth/session'; +import { getDatabase } from '$lib/server/database'; + +export const load: PageServerLoad = async ({ cookies }) => { + await requireDataView(await getSessionUser(cookies)); + const database = await getDatabase(); + const attributes = await (await database.prepare(` + SELECT id,name,description,unit,type,category,calculation_type,cap_value, + gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max + FROM stat_attributes + WHERE active=1 + ORDER BY category COLLATE NOCASE,type COLLATE NOCASE,name COLLATE NOCASE + `)).all(); + + return { + attributes, + attributeSummary: { + total: attributes.length, + documented: attributes.filter((attribute) => String(attribute.description ?? '').trim()).length + } + }; +}; diff --git a/src/routes/isac/mechanic-reference/+page.svelte b/src/routes/isac/mechanic-reference/+page.svelte new file mode 100644 index 0000000..6d4e696 --- /dev/null +++ b/src/routes/isac/mechanic-reference/+page.svelte @@ -0,0 +1,1061 @@ + + + + Mechanic script reference — SHD.build ISAC + + + +
+
+

CALCULATION MODEL REFERENCE

+

MECHANIC SCRIPT LANGUAGE

+

Read the generated scripts used to explain combat mechanics. ISAC stores and evaluates typed data; displayed scripts are read-only.

+
+ +
+ +
+ + +
+
+
+
01

READING GENERATED SCRIPTS

+ READ ONLY +
+

Editors create structured events, conditions, state changes, and outcomes. ISAC transcribes those records into a compact script so you can inspect calculation order without reading JSON or application code.

+
+ {#each statements as statement} +
{statement.token}{statement.meaning}
+ {/each} +
+
+ +
+
+
02

NORMALIZED COMBAT EVENTS

+ {Object.keys(MECHANIC_EVENT_REGISTRY).length} EVENTS +
+

Each branch begins with one event. Event values carry details such as target identity, distance, critical state, movement state, and nearby entity counts.

+
+ {#each Object.values(MECHANIC_EVENT_REGISTRY) as event} +
+
{event.label}{event.key}
+

{event.description}

+
+ {/each} +
+
+ +
+
+
03

ACTION FAMILY RECIPES

+ {Object.keys(TRIGGER_ACTION_REGISTRY).length} RECIPES +
+

Action Families seed common mechanic shapes. They map familiar game language to normalized events and validated parameters, then copy an editable branch into the source item.

+
+ {#each Object.values(TRIGGER_ACTION_REGISTRY) as action} +
{action.label}{action.key}
+ {/each} +
+
+ +
+
04

VALUES, EXPRESSIONS, AND CONDITIONS

+

Expressions combine numbers with references through addition, subtraction, multiplication, division, minimum, maximum, and clamp operations.

+
+
+

VALUE SOURCES

+
+ {#each valueSources as item}
{item.syntax}{item.use}
{/each} +
+
+
+

CONDITION OPERATORS

+
+ {#each predicateOperators as item}
{item.syntax}{item.use}
{/each} +
+
+
+
+ +
+
+
05

ISAC ATTRIBUTE VARIABLES

+ {data.attributeSummary.documented}/{data.attributeSummary.total} DOCUMENTED +
+

Attribute outcomes reference the current ISAC Attribute registry. Names and descriptions below update with that data, so improving an Attribute description improves this reference.

+
+ + {matchingAttributes.length} OF {data.attributeSummary.total} SHOWN +
+
+ {#each matchingAttributes as attribute (attribute.id)} +
+
+
+ {attribute.name}{attribute.unit ? ` (${attribute.unit})` : ''} + {attribute.id} +
+
+ {String(attribute.category ?? 'UNCATEGORIZED').toLocaleUpperCase()} + {String(attribute.type ?? 'UNCLASSIFIED').toLocaleUpperCase()} + {String(attribute.calculation_type ?? 'UNSPECIFIED').toLocaleUpperCase()} +
+
+

+ {String(attribute.description ?? '').trim() || 'DESCRIPTION NEEDED · Add calculation guidance in the ISAC Attribute editor.'} +

+ {#if sourceRanges(attribute).length || attribute.cap_value !== null} +
+ {#each sourceRanges(attribute) as source} + {source.label}{source.value}{attribute.unit ?? ''} + {/each} + {#if attribute.cap_value !== null} + CAP{attribute.cap_value}{attribute.unit ?? ''} + {/if} +
+ {/if} +
+ {:else} +
+ NO ATTRIBUTE VARIABLES MATCH + Change the search terms to inspect another Attribute. +
+ {/each} +
+
+ +
+
06

EVENT REDUCERS

+

Reducers turn matching event history or current scenario data into an activation. A periodic reducer must name its interval; ISAC excludes a branch when its cadence is unknown.

+
+ {#each reducers as item}
{item.syntax}{item.use}
{/each} +
+
+ +
+
07

TRACKED STATE

+

State pools track stacks, counts, scalars, marks, or resources. Agent pools belong to the build; target pools keep separate values for each enemy.

+
+ {#each mutations as item}
{item.syntax}{item.use}
{/each} +
+ +
+ +
+
08

OUTCOMES AND ORDER

+

Outcome phases control calculation order. This distinction matters when one event changes Health before another outcome reads current Health.

+
+
+

PHASES

+
+ {#each phases as item}
{item.syntax}{item.use}
{/each} +
+
+
+

RESULT TYPES

+
+ {#each outcomeKinds as item}
{item.token}{item.detail}
{/each} +
+

VALUE BASES

+
{#each bases as basis}{basis}{/each}
+
+
+
+ +
+
09

BUILDER SCENARIOS

+
+
+ MAXIMUM POTENTIAL +

Uses each mechanic’s configured best-case values to calculate a ceiling. It applies peak stacks and maximum supported target or ally counts.

+
+
+ CUSTOM SCENARIO +

Uses player-selected toggles, counts, percentages, and cadence inputs to model a specific combat situation.

+
+
+ + +
+ +
+
10

WORKED MECHANICS

+
+
+

SUPERIOR FIREPOWER

+

Nine hits on one target add a Relentless stack. Each write refreshes the ten-second pool timer.

+
+
+

HAPPY SURVIVAL

+

A periodic branch restores Health from nearby-enemy count. Each shot pays its Health cost before calculating additional damage from current Health.

+
+
+
+ + +
+
+
+
+ + diff --git a/src/routes/isac/protocols/+page.server.ts b/src/routes/isac/protocols/+page.server.ts index 4d3b079..c5625d9 100644 --- a/src/routes/isac/protocols/+page.server.ts +++ b/src/routes/isac/protocols/+page.server.ts @@ -8,6 +8,13 @@ import { randomUUID } from 'node:crypto'; import type { Actions, PageServerLoad } from './$types'; import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons'; import { getPendingEntities } from '$lib/server/data-moderation'; +import { + conditionalRuleSnapshot, + loadConditionalRuleDraft, + parseConditionalRulePayload, + saveConditionalRuleInTransaction +} from '$lib/server/conditional-effects'; +import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes'; const qualities = ['Standard', 'Specialized', 'Superior', 'High-end']; const attributeTypes = ['Firepower', 'Toughness', 'Engineering']; @@ -18,14 +25,11 @@ const expectedSecondaryAttributes: Record> = { Engineering: { Core: 'Engineering', Damage: 'Skill Damage', Health: 'Health' } }; -const parseList = (value: FormDataEntryValue | null): any[] => { - try { - const parsed = JSON.parse(String(value ?? '[]')); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -}; +const mechanicOutcomes = (rule: ReturnType) => + rule.clauses.flatMap((clause) => clause.outcomes); +const descriptionOutcomes = (rule: ReturnType) => + mechanicOutcomes(rule).filter((outcome) => outcome.descriptionSlot) + .sort((left, right) => Number(left.descriptionSlot) - Number(right.descriptionSlot)); async function protocolSnapshot(id: string) { const database = await getDatabase(); @@ -35,8 +39,8 @@ async function protocolSnapshot(id: string) { icon: await gameDataIconMetadata(database, 'os_protocol', id), primaryEffect: await (await database.prepare('SELECT * FROM os_protocol_primary_effects WHERE protocol_id=?')).get(id) ?? null, talent: talent ?? null, - talentTriggers: talent ? await (await database.prepare('SELECT trigger_id FROM os_protocol_talent_triggers WHERE talent_id=? ORDER BY trigger_id')).all(talent.id) : [], - talentEffects: talent ? await (await database.prepare('SELECT * FROM os_protocol_talent_effects WHERE talent_id=? ORDER BY sort_order')).all(talent.id) : [] + talentEffects: talent ? await (await database.prepare('SELECT * FROM os_protocol_talent_effects WHERE talent_id=? ORDER BY sort_order')).all(talent.id) : [], + conditional: talent ? await conditionalRuleSnapshot(database, 'os_protocol_talent', String(talent.id)) : null }; } @@ -92,21 +96,18 @@ export const load: PageServerLoad = async ({ cookies, url }) => { (await database.prepare(`SELECT COUNT(*) AS total FROM os_protocols ${where}`)).get(...args) ]); const pagination = getPagination(url, Number(filtered?.total ?? 0)); - const [protocols, primaryEffects, profiles, secondaryEffects, talents, talentTriggers, talentEffects, stats, triggers, icons, pending] = await Promise.all([ + const [protocols, primaryEffects, profiles, secondaryEffects, talents, talentEffects, stats, icons, pending] = await Promise.all([ (await database.prepare(`SELECT * FROM os_protocols ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset), (await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(), (await database.prepare('SELECT * FROM os_protocol_secondary_profiles ORDER BY quality,primary_attribute_type')).all(), (await database.prepare('SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role')).all(), (await database.prepare('SELECT * FROM os_protocol_talents')).all(), - (await database.prepare('SELECT * FROM os_protocol_talent_triggers')).all(), (await database.prepare('SELECT * FROM os_protocol_talent_effects ORDER BY sort_order')).all(), (await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), - (await database.prepare('SELECT id,name,description FROM talent_triggers WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), (await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='os_protocol'")).all(), getPendingEntities(user.id, [ { module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] }, - { module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] }, - { module: 'protocols', action: 'save', kind: 'protocol', fields: ['quality', 'attributeType', 'primaryStatId', 'primaryBaseValue', 'talentName', 'descriptionTemplate', 'talentEffects', 'triggerIds'] } + { module: 'protocols', action: 'save', kind: 'protocol', fields: ['quality', 'attributeType', 'primaryStatId', 'primaryBaseValue', 'talentName', 'descriptionTemplate', 'conditionalRule'] } ]) ]); const configuredProfiles = profiles.map((profile) => ({ ...profile, effects: secondaryEffects.filter((effect) => effect.profile_id === profile.id) })); @@ -125,12 +126,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => { talent: { name: protocol.talentName, description_template: protocol.descriptionTemplate, - effects: (() => { try { return JSON.parse(String(protocol.talentEffects ?? '[]')); } catch { return []; } })(), - triggerIds: Array.isArray(protocol.triggerIds) ? protocol.triggerIds : [protocol.triggerIds].filter(Boolean) + conditionalRule: (() => { try { return JSON.parse(String(protocol.conditionalRule ?? 'null')); } catch { return null; } })() } })); - return { - protocols: [...pendingProtocols, ...protocols.map((protocol) => { + const configuredProtocols = await Promise.all(protocols.map(async (protocol) => { const talent = talents.find((item) => item.protocol_id === protocol.id); return { ...protocol, @@ -139,16 +138,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => { secondaryProfile: configuredProfiles.find((profile) => profile.quality === protocol.quality && profile.primary_attribute_type === protocol.primary_attribute_type) ?? null, talent: talent ? { ...talent, - triggerIds: talentTriggers.filter((item) => item.talent_id === talent.id).map((item) => String(item.trigger_id)), - effects: talentEffects.filter((item) => item.talent_id === talent.id) + effects: talentEffects.filter((item) => item.talent_id === talent.id), + conditionalRule: await loadConditionalRuleDraft(database, 'os_protocol_talent', String(talent.id), ['base']) } : null }; - })], + })); + return { + protocols: [...pendingProtocols, ...configuredProtocols], profiles: qualities.flatMap((quality) => attributeTypes.map((attributeType) => configuredProfiles.find((profile) => profile.quality === quality && profile.primary_attribute_type === attributeType ) ?? { id: '', quality, primary_attribute_type: attributeType, effects: [] })), stats: [...pending.filter((item) => item.kind === 'attribute'), ...stats], - triggers: [...pending.filter((item) => item.kind === 'trigger'), ...triggers], summary: { ...summary, total: Number(summary?.total ?? 0) + pending.filter((item) => item.kind === 'protocol').length, pending: pending.filter((item) => item.kind === 'protocol').length }, filter, pagination, search, sort }; }; @@ -191,44 +191,73 @@ export const actions: Actions = { const primaryBaseValue = Number(form.get('primaryBaseValue')); const talentName = String(form.get('talentName') ?? '').normalize('NFKC').trim(); const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').normalize('NFKC').trim(); - const triggerIds = [...new Set(form.getAll('triggerIds').map(String).filter(Boolean))]; - const talentEffects = parseList(form.get('talentEffects')).map((item, index) => ({ - statId: String(item.statId ?? ''), baseValue: Number(item.baseValue), sortOrder: index + 1 - })); + const database = await getDatabase(); + let conditionalRule; + try { + const conditionalRuleRaw = form.get('conditionalRule'); + const activeStats = await (await database.prepare('SELECT id FROM stat_attributes WHERE active=1')).all(); + conditionalRule = parseConditionalRulePayload(conditionalRuleRaw, [ + { key: 'base', variantKey: 'base' } + ], { + allowedAttributeIds: new Set(activeStats.map((row) => String(row.id))), + allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)) + }); + } catch (error) { + return fail(400, { protocolError: error instanceof Error ? error.message : 'The conditional mechanic is invalid.' }); + } if (name.length < 2 || name.length > 80) return fail(400, { protocolError: 'Protocol names must be 2–80 characters.' }); if (!qualities.includes(quality) || !attributeTypes.includes(attributeType)) return fail(400, { protocolError: 'Choose a valid quality and primary attribute type.' }); if (!primaryStatId || !Number.isFinite(primaryBaseValue) || primaryBaseValue < 0) return fail(400, { protocolError: 'Choose the protocol’s unique primary stat and a non-negative level-1 base value.' }); - if (talentName.length < 2 || talentName.length > 80 || !descriptionTemplate || !triggerIds.length || !talentEffects.length) return fail(400, { protocolError: 'The unique talent requires a name, description template, trigger, and at least one effect.' }); - if (talentEffects.length > 12 || talentEffects.some((item) => !item.statId || !Number.isFinite(item.baseValue))) return fail(400, { protocolError: 'Talent effects require valid attributes and base values.' }); - if (new Set(talentEffects.map((item) => item.statId)).size !== talentEffects.length) return fail(400, { protocolError: 'Each talent effect may reference an attribute only once.' }); + const outcomes = mechanicOutcomes(conditionalRule); + const numberedOutcomes = descriptionOutcomes(conditionalRule); + const durationSeconds = conditionalRule.pools.find((pool) => pool.expiresAfterSeconds)?.expiresAfterSeconds ?? null; + const maxStacks = conditionalRule.pools.length ? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1)) : null; + if (talentName.length < 2 || talentName.length > 80 || !descriptionTemplate || !conditionalRule.clauses.length || !outcomes.length) return fail(400, { protocolError: 'The unique talent requires a name, description template, mechanic branch, and at least one outcome.' }); const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1])); - if (placeholders.some((index) => index < 1 || index > talentEffects.length)) return fail(400, { protocolError: 'Description placeholders must reference configured talent effects.' }); - const database = await getDatabase(); + if (placeholders.some((index) => !numberedOutcomes.some((outcome) => outcome.descriptionSlot === index))) return fail(400, { protocolError: 'Description placeholders must reference configured numbered outcomes.' }); + if (descriptionTemplate.includes('{duration}') && durationSeconds === null) return fail(400, { protocolError: 'The description uses {duration}, but no expiring state pool is defined.' }); + if (descriptionTemplate.includes('{max_stacks}') && !maxStacks) return fail(400, { protocolError: 'The description uses {max_stacks}, but no state pool maximum is defined.' }); if (!await (await database.prepare('SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?')).get(quality, attributeType)) return fail(400, { protocolError: 'Configure the shared secondary profile for this quality and attribute type first.' }); if (await (await database.prepare('SELECT id FROM os_protocols WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) return fail(409, { protocolError: 'That OS Protocol already exists.' }); const existingTalent = id ? await (await database.prepare('SELECT id FROM os_protocol_talents WHERE protocol_id=?')).get(id) : null; if (await (await database.prepare('SELECT id FROM os_protocol_talents WHERE name=? COLLATE NOCASE AND id<>?')).get(talentName, existingTalent?.id ?? 'new')) return fail(409, { protocolError: 'That protocol talent name is already in use.' }); - const statIds = [...new Set([primaryStatId, ...talentEffects.map((item) => item.statId)])]; + const outcomeStatIds = outcomes.map((item) => item.attributeId).filter((value): value is string => Boolean(value)); + const statIds = [...new Set([primaryStatId, ...outcomeStatIds])]; const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).get(...statIds); if (Number(validStats?.count) !== statIds.length) return fail(400, { protocolError: 'One or more selected attributes are unavailable.' }); - const validTriggers = await (await database.prepare(`SELECT COUNT(*) AS count FROM talent_triggers WHERE active=1 AND id IN (${triggerIds.map(() => '?').join(',')})`)).get(...triggerIds); - if (Number(validTriggers?.count) !== triggerIds.length) return fail(400, { protocolError: 'One or more selected triggers are unavailable.' }); const protocolId = id || randomUUID(), talentId = String(existingTalent?.id ?? randomUUID()), now = new Date().toISOString(); const before = id ? await protocolSnapshot(id) : null; - if (id) { - if (!await auditRecord('os_protocols', id)) return fail(404, { protocolError: 'OS Protocol not found.' }); - await (await database.prepare('UPDATE os_protocols SET name=?,quality=?,primary_attribute_type=?,active=1,updated_at=? WHERE id=?')).run(name, quality, attributeType, now, id); - } else await (await database.prepare('INSERT INTO os_protocols(id,name,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?,?)')).run(protocolId, name, quality, attributeType, now, now); - await (await database.prepare(`INSERT INTO os_protocol_primary_effects(id,protocol_id,stat_attribute_id,base_value) VALUES(?,?,?,?) - ON CONFLICT(protocol_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,base_value=excluded.base_value`)).run(randomUUID(), protocolId, primaryStatId, primaryBaseValue); - await (await database.prepare(`INSERT INTO os_protocol_talents(id,protocol_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?) - ON CONFLICT(protocol_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)).run(talentId, protocolId, talentName, descriptionTemplate, now, now); - await (await database.prepare('DELETE FROM os_protocol_talent_triggers WHERE talent_id=?')).run(talentId); - for (const triggerId of triggerIds) await (await database.prepare('INSERT INTO os_protocol_talent_triggers(talent_id,trigger_id) VALUES(?,?)')).run(talentId, triggerId); - await (await database.prepare('DELETE FROM os_protocol_talent_effects WHERE talent_id=?')).run(talentId); - for (const effect of talentEffects) await (await database.prepare('INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) VALUES(?,?,?,?,?)')).run(randomUUID(), talentId, effect.statId, effect.baseValue, effect.sortOrder); - await applyIconMutation(database, 'os_protocol', protocolId, iconMutation, operator.id); - await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'os_protocol.updated' : 'os_protocol.created', entityId: protocolId, before, after: await protocolSnapshot(protocolId) }); + if (id && !before?.record) return fail(404, { protocolError: 'OS Protocol not found.' }); + await database.exec('BEGIN'); + try { + if (id) { + await (await database.prepare('UPDATE os_protocols SET name=?,quality=?,primary_attribute_type=?,active=1,updated_at=? WHERE id=?')).run(name, quality, attributeType, now, id); + } else await (await database.prepare('INSERT INTO os_protocols(id,name,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?,?)')).run(protocolId, name, quality, attributeType, now, now); + await (await database.prepare(`INSERT INTO os_protocol_primary_effects(id,protocol_id,stat_attribute_id,base_value) VALUES(?,?,?,?) + ON CONFLICT(protocol_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,base_value=excluded.base_value`)).run(randomUUID(), protocolId, primaryStatId, primaryBaseValue); + await (await database.prepare(`INSERT INTO os_protocol_talents(id,protocol_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?) + ON CONFLICT(protocol_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)).run(talentId, protocolId, talentName, descriptionTemplate, now, now); + await (await database.prepare('DELETE FROM os_protocol_talent_effects WHERE talent_id=?')).run(talentId); + let compatibilitySortOrder = 0; + for (const outcome of numberedOutcomes) { + if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId) continue; + compatibilitySortOrder += 1; + await (await database.prepare('INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) VALUES(?,?,?,?,?)')).run( + randomUUID(), + talentId, + outcome.attributeId, + outcome.variants.base.value, + compatibilitySortOrder + ); + } + await saveConditionalRuleInTransaction(database, 'os_protocol_talent', talentId, conditionalRule); + await applyIconMutation(database, 'os_protocol', protocolId, iconMutation, operator.id); + await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'os_protocol.updated' : 'os_protocol.created', entityId: protocolId, before, after: await protocolSnapshot(protocolId) }); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } return { protocolSaved: true, protocolMode: id ? 'edit' : 'add', resultToken: randomUUID() }; }, archive: async ({ request, cookies }) => { diff --git a/src/routes/isac/protocols/+page.svelte b/src/routes/isac/protocols/+page.svelte index 3c2747d..7e9ff79 100644 --- a/src/routes/isac/protocols/+page.svelte +++ b/src/routes/isac/protocols/+page.svelte @@ -10,9 +10,10 @@ import IconUploadField from '$lib/components/IconUploadField.svelte'; import ItemIcon from '$lib/components/ItemIcon.svelte'; import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; + import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte'; + import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor'; let { data, form } = $props(); - type TalentEffect = { stat_attribute_id: string; base_value: number | string }; const qualities = ['Standard', 'Specialized', 'Superior', 'High-end']; const attributeTypes = ['Firepower', 'Toughness', 'Engineering']; const secondaryRoles = ['Core', 'Damage', 'Health']; @@ -21,7 +22,15 @@ Damage: type === 'Engineering' ? 'Skill Damage' : 'All Weapon Damage', Health: 'Health' }); - const blankTalent = () => ({ id: '', name: '', description_template: '', triggerIds: [], effects: [{ stat_attribute_id: '', base_value: '' }] }); + const talentValueColumns = [{ key: 'base', variantKey: 'base', label: 'BASE' }]; + const blankRule = (): ConditionalMechanicDraftV1 => ({ + version: 1, + simulationReadiness: 'captured_only', + scenarioInputs: [], + pools: [], + clauses: [{key:'primary',label:'Primary',event:'tick',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'self',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{base:{value:0}}}]}] + }); + const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() }); const blank = () => ({ id: '', name: '', iconUrl: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, talent: blankTalent() }); let editing = $state(null); let profileEditing = $state(null); @@ -46,16 +55,23 @@ const profileFor = (quality: string, type: string) => data.profiles.find((profile) => profile.quality === quality && profile.primary_attribute_type === type); const profileEffect = (profile: any, role: string) => profile.effects.find((effect: any) => effect.effect_role === role); const configuredProfile = () => profileFor(editing.quality, editing.primary_attribute_type)?.effects.length === 3; - const talentPreview = (upgrades: number) => editing.talent.description_template.replace(/\{(\d+)\}/g, (placeholder: string, rawIndex: string) => { - const effect = editing.talent.effects[Number(rawIndex) - 1]; - return effect ? format(upgradeValue(effect.base_value, upgrades), effect.stat_attribute_id) : placeholder; + const previewOutcomes = () => editing.talent.conditionalRule.clauses + .flatMap((clause: any) => clause.outcomes) + .filter((outcome: any) => outcome.descriptionSlot) + .sort((left: any, right: any) => left.descriptionSlot - right.descriptionSlot); + const talentPreview = (upgrades: number) => editing.talent.description_template.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => { + const duration = editing.talent.conditionalRule.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds; + const maxStacks = editing.talent.conditionalRule.pools.length ? Math.max(...editing.talent.conditionalRule.pools.map((pool: any) => pool.max ?? 1)) : null; + if (expression === 'duration') return duration == null ? placeholder : `${duration} seconds`; + if (expression === 'max_stacks') return maxStacks == null ? placeholder : `${maxStacks} stacks`; + const outcome = previewOutcomes().find((item: any) => item.descriptionSlot === Number(expression)); + const baseValue = outcome?.variants?.base?.value; + const scalesWithUpgrades = JSON.stringify(outcome?.variants?.base?.expression ?? {}) + .includes('protocolTalentUpgrades'); + return outcome && baseValue !== '' && baseValue !== null && baseValue !== undefined + ? format(scalesWithUpgrades ? upgradeValue(baseValue, upgrades) : baseValue, outcome.attributeId) + : placeholder; }); - const addTalentEffect = () => { - if (editing.talent.effects.length < 12) editing.talent.effects = [...editing.talent.effects, { stat_attribute_id: '', base_value: '' }]; - }; - const removeTalentEffect = (index: number) => { - if (editing.talent.effects.length > 1) editing.talent.effects = editing.talent.effects.filter((_: TalentEffect, effectIndex: number) => effectIndex !== index); - }; const editProfile = (profile: any) => { profileEditing = { ...profile, @@ -69,8 +85,7 @@ primaryEffect: protocol.primaryEffect ? { ...protocol.primaryEffect } : { stat_attribute_id: '', base_value: '' }, talent: protocol.talent ? { ...protocol.talent, - triggerIds: [...protocol.talent.triggerIds], - effects: protocol.talent.effects.map((effect: TalentEffect) => ({ ...effect })) + conditionalRule: protocol.talent.conditionalRule ?? blankRule() } : blankTalent() }; profileEditing = null; @@ -136,7 +151,7 @@

{editing.id ? 'MODIFY PROTOCOL' : 'NEW PROTOCOL'}

{editing.id ? editing.name : 'OS PROTOCOL DEFINITION'}

- ({ statId: effect.stat_attribute_id, baseValue: effect.base_value })))} /> +
@@ -152,16 +167,19 @@
PRIMARY STAT
LEVEL {protocolLevel} PREVIEW{format(levelValue(editing.primaryEffect.base_value, protocolLevel), editing.primaryEffect.stat_attribute_id)}LEVEL 50 · {format(levelValue(editing.primaryEffect.base_value, 50), editing.primaryEffect.stat_attribute_id)}
-
UNIQUE PROTOCOL TALENTOWNED EXCLUSIVELY BY THIS PROTOCOL · FIVE LINEAR UPGRADES
{editing.talent.id ? 'LINKED RECORD' : 'NEW RECORD'}
+
UNIQUE PROTOCOL TALENTOWNED EXCLUSIVELY BY THIS PROTOCOL · FIVE LINEAR UPGRADES FOR MARKED VALUES
{editing.talent.id ? 'LINKED RECORD' : 'NEW RECORD'}
-
{#each [1, 2, 3, 4, 5] as upgrade}{/each}
UPGRADE {talentUpgrade} / 5 · {talentUpgrade === 5 ? '2× EFFECT VALUE' : `${Math.round((1 + talentUpgrade / 5) * 100)}% EFFECT VALUE`}

{talentPreview(talentUpgrade) || 'Talent description preview'}

-
-
ACTIVATION TRIGGERSREUSABLE TRIGGER DEFINITIONS
{editing.talent.triggerIds.length}
-
TALENT EFFECTSBASE VALUE AT ZERO UPGRADES
{#each editing.talent.effects as effect, index}
{index + 1}{format(upgradeValue(effect.base_value, 5), effect.stat_attribute_id)} AT +5
{/each}
-
+
{#each [1, 2, 3, 4, 5] as upgrade}{/each}
UPGRADE {talentUpgrade} / 5 · {talentUpgrade === 5 ? '2× SCALABLE VALUES' : `${Math.round((1 + talentUpgrade / 5) * 100)}% SCALABLE VALUES`}

{talentPreview(talentUpgrade) || 'Talent description preview'}

+
{#if form?.protocolError}

{form.protocolError}

{/if} -
{#if editing.id}{/if}
+
{#if editing.id}{/if}
{/if} @@ -172,7 +190,7 @@
{#each data.protocols as protocol} -
{protocol.name}{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}
{protocol.quality}{protocol.primary_attribute_type}{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}
{protocol.talent?.name ?? 'NOT CONFIGURED'}◆◆◆◆◆ 5 UPGRADES · 2× MAX
{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}{#if protocol.pending}{:else}{/if}
+
{protocol.name}{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}
{protocol.quality}{protocol.primary_attribute_type}{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}
{protocol.talent?.name ?? 'NOT CONFIGURED'}◆◆◆◆◆ {protocol.talent?.conditionalRule?.clauses?.length || protocol.pending ? 'MODELED · ' : 'CONFIG REQUIRED · '}5 UPGRADES
{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}{#if protocol.pending}{:else}{/if}
{#if editing?.id === protocol.id}{@render protocolEditor()}{/if} {:else}
NO OS PROTOCOLS REGISTEREDConfigure shared secondary profiles, then add the first protocol.
{/each}
diff --git a/src/routes/isac/skillchips/+page.server.ts b/src/routes/isac/skillchips/+page.server.ts index 6275b7c..f8441e9 100644 --- a/src/routes/isac/skillchips/+page.server.ts +++ b/src/routes/isac/skillchips/+page.server.ts @@ -9,23 +9,19 @@ import { refreshAbilityFocuses } from '$lib/server/specialization-readiness'; import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; import type { Actions, PageServerLoad } from './$types'; +import { + conditionalRuleSnapshot, + loadConditionalRuleDraft, + parseConditionalRulePayload, + saveConditionalRuleInTransaction +} from '$lib/server/conditional-effects'; +import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes'; -type Effect = { statId: string; minValue: number; maxValue: number; sortOrder: number }; - -const parseEffects = (value: FormDataEntryValue | null): Effect[] => { - try { - const parsed = JSON.parse(String(value ?? '[]')); - if (!Array.isArray(parsed)) return []; - return parsed.map((item, index) => ({ - statId: String(item.statId ?? ''), - minValue: Number(item.minValue), - maxValue: Number(item.maxValue), - sortOrder: index + 1 - })); - } catch { - return []; - } -}; +const mechanicOutcomes = (rule: ReturnType) => + rule.clauses.flatMap((clause) => clause.outcomes); +const descriptionOutcomes = (rule: ReturnType) => + mechanicOutcomes(rule).filter((outcome) => outcome.descriptionSlot) + .sort((left, right) => Number(left.descriptionSlot) - Number(right.descriptionSlot)); async function snapshot(id: string) { const database = await getDatabase(); @@ -37,8 +33,8 @@ async function snapshot(id: string) { primaryOptions: await (await database.prepare('SELECT stat_attribute_id FROM skill_chip_primary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id')).all(id), secondaryOptions: await (await database.prepare('SELECT stat_attribute_id FROM skill_chip_secondary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id')).all(id), talent: talent ?? null, - talentTriggers: talent ? await (await database.prepare('SELECT trigger_id FROM skill_chip_talent_triggers WHERE talent_id=? ORDER BY trigger_id')).all(talent.id) : [], - talentEffects: talent ? await (await database.prepare('SELECT * FROM skill_chip_talent_effects WHERE talent_id=? ORDER BY sort_order')).all(talent.id) : [] + talentEffects: talent ? await (await database.prepare('SELECT * FROM skill_chip_talent_effects WHERE talent_id=? ORDER BY sort_order')).all(talent.id) : [], + conditional: talent ? await conditionalRuleSnapshot(database, 'skill_chip_talent', String(talent.id)) : null }; } @@ -64,7 +60,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { ]; const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const args = search ? [pattern] : []; - const [summary, filtered, pendingStats, pendingTriggers, pendingSets, pendingAbilities, pendingSpecializations] = await Promise.all([ + const [summary, filtered, pendingStats, pendingSets, pendingAbilities, pendingSpecializations] = await Promise.all([ (await database.prepare(`SELECT COUNT(*) AS total, SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active, SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived, @@ -73,22 +69,19 @@ export const load: PageServerLoad = async ({ cookies, url }) => { FROM skill_chip_sets`)).get(), (await database.prepare(`SELECT COUNT(*) AS total FROM skill_chip_sets sets ${where}`)).get(...args), getPendingEntities(user.id, [{ module: 'attributes', action: 'save', kind: 'stat', fields: ['unit', 'chipMin', 'chipMax'] }]), - getPendingEntities(user.id, [{ module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] }]), - getPendingEntities(user.id, [{ module: 'skillchips', action: 'save', kind: 'skillchip', fields: ['abilityId', 'twoPieceStatId', 'twoPieceMin', 'twoPieceMax', 'primaryStatIds', 'secondaryStatIds', 'talentName', 'descriptionTemplate', 'talentEffects', 'triggerIds'] }]), + getPendingEntities(user.id, [{ module: 'skillchips', action: 'save', kind: 'skillchip', fields: ['abilityId', 'twoPieceStatId', 'twoPieceMin', 'twoPieceMax', 'primaryStatIds', 'secondaryStatIds', 'talentName', 'descriptionTemplate', 'conditionalRule'] }]), getPendingEntities(user.id, [{ module: 'specializations', action: 'saveAbility', kind: 'ability', fields: ['specializationId'] }]), getPendingEntities(user.id, [{ module: 'specializations', action: 'saveSpecialization', kind: 'specialization' }]) ]); const pagination = getPagination(url, Number(filtered?.total ?? 0)); - const [sets, twoPiece, primaryOptions, secondaryOptions, talents, talentTriggers, talentEffects, stats, triggers, abilities, specializations, abilityChipCounts, icons] = await Promise.all([ + const [sets, twoPiece, primaryOptions, secondaryOptions, talents, talentEffects, stats, abilities, specializations, abilityChipCounts, icons] = await Promise.all([ (await database.prepare(`SELECT sets.* FROM skill_chip_sets sets ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset), (await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')).all(), (await database.prepare('SELECT * FROM skill_chip_primary_options')).all(), (await database.prepare('SELECT * FROM skill_chip_secondary_options')).all(), (await database.prepare('SELECT * FROM skill_chip_talents')).all(), - (await database.prepare('SELECT * FROM skill_chip_talent_triggers')).all(), (await database.prepare('SELECT * FROM skill_chip_talent_effects ORDER BY sort_order')).all(), (await database.prepare('SELECT id,name,unit,skill_chip_min,skill_chip_max FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), - (await database.prepare('SELECT id,name,description FROM talent_triggers WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), (await database.prepare('SELECT id,name,specialization_id FROM specialization_abilities WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), (await database.prepare('SELECT id,name FROM specializations WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), (await database.prepare(`SELECT specialization_ability_id,COUNT(*) AS count FROM skill_chip_sets @@ -121,7 +114,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { Boolean(set.abilityId) && Array.isArray(set.primaryStatIds) && Array.isArray(set.secondaryStatIds) - && Array.isArray(set.triggerIds) + && Boolean(set.conditionalRule) ); const pendingSetRows = filter === 'archived' ? [] : currentPendingSets .filter((set) => filter !== 'assigned' || Boolean(set.abilityId)) @@ -140,12 +133,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => { talent: { name: set.talentName, description_template: set.descriptionTemplate, - effects: (() => { try { return JSON.parse(String(set.talentEffects ?? '[]')); } catch { return []; } })(), - triggerIds: set.triggerIds + conditionalRule: (() => { try { return JSON.parse(String(set.conditionalRule ?? 'null')); } catch { return null; } })() } })); - return { - sets: [...pendingSetRows, ...sets.map((set) => { + const configuredSets = await Promise.all(sets.map(async (set) => { const talent = talents.find((item) => item.skill_chip_set_id === set.id); const icon = icons.find((item) => item.entity_id === set.id); return { @@ -156,14 +147,15 @@ export const load: PageServerLoad = async ({ cookies, url }) => { secondaryStatIds: secondaryOptions.filter((item) => item.skill_chip_set_id === set.id).map((item) => String(item.stat_attribute_id)), talent: talent ? { ...talent, - triggerIds: talentTriggers.filter((item) => item.talent_id === talent.id).map((item) => String(item.trigger_id)), - effects: talentEffects.filter((item) => item.talent_id === talent.id) + effects: talentEffects.filter((item) => item.talent_id === talent.id), + conditionalRule: await loadConditionalRuleDraft(database, 'skill_chip_talent', String(talent.id), ['minimum', 'maximum']) } : null }; - })], + })); + return { + sets: [...pendingSetRows, ...configuredSets], stats: availableStats, rollStats: availableStats.filter((stat) => stat.skill_chip_min !== null && stat.skill_chip_max !== null), - triggers: [...pendingTriggers, ...triggers], abilities: loadedAbilities, summary: { active: Number(summary?.active ?? 0), @@ -194,20 +186,37 @@ export const actions: Actions = { const secondaryStatIds = [...new Set(form.getAll('secondaryStatIds').map(String).filter(Boolean))]; const talentName = String(form.get('talentName') ?? '').normalize('NFKC').trim(); const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').normalize('NFKC').trim(); - const triggerIds = [...new Set(form.getAll('triggerIds').map(String).filter(Boolean))]; - const effects = parseEffects(form.get('talentEffects')); + const database = await getDatabase(); + let conditionalRule; + try { + const conditionalRuleRaw = form.get('conditionalRule'); + const activeStats = await (await database.prepare('SELECT id FROM stat_attributes WHERE active=1')).all(); + conditionalRule = parseConditionalRulePayload(conditionalRuleRaw, [ + { key: 'minimum', variantKey: 'minimum' }, + { key: 'maximum', variantKey: 'maximum' } + ], { + allowedAttributeIds: new Set(activeStats.map((row) => String(row.id))), + allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)) + }); + } catch (error) { + return fail(400, { skillChipError: error instanceof Error ? error.message : 'The conditional mechanic is invalid.' }); + } if (name.length < 2 || name.length > 80) return fail(400, { skillChipError: 'Skill Chip names must be 2–80 characters.' }); if (!abilityId) return fail(400, { skillChipError: 'Assign this Skill Chip to a specialization ability.' }); if (!twoPieceStatId || !Number.isFinite(twoPieceMin) || !Number.isFinite(twoPieceMax) || twoPieceMax < twoPieceMin) return fail(400, { skillChipError: 'Configure a valid 2-piece stat range.' }); if (!primaryStatIds.length) return fail(400, { skillChipError: 'Configure at least one possible primary stat.' }); if (secondaryStatIds.length < 2) return fail(400, { skillChipError: 'Configure at least two possible secondary stats.' }); if (primaryStatIds.some((statId) => secondaryStatIds.includes(statId))) return fail(400, { skillChipError: 'Primary and secondary stat pools must be distinct.' }); - if (talentName.length < 2 || talentName.length > 80 || !descriptionTemplate || !triggerIds.length || !effects.length) return fail(400, { skillChipError: 'The 3-piece talent requires a name, description, trigger, and effect.' }); - if (effects.length > 12 || effects.some((effect) => !effect.statId || !Number.isFinite(effect.minValue) || !Number.isFinite(effect.maxValue) || effect.maxValue < effect.minValue)) return fail(400, { skillChipError: 'Every talent effect requires a valid minimum and maximum.' }); - if (new Set(effects.map((effect) => effect.statId)).size !== effects.length) return fail(400, { skillChipError: 'Each talent effect may reference a stat only once.' }); + const outcomes = mechanicOutcomes(conditionalRule); + const numberedOutcomes = descriptionOutcomes(conditionalRule); + const durationSeconds = conditionalRule.pools.find((pool) => pool.expiresAfterSeconds)?.expiresAfterSeconds ?? null; + const maxStacks = conditionalRule.pools.length ? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1)) : null; + if (talentName.length < 2 || talentName.length > 80 || !descriptionTemplate || !conditionalRule.clauses.length || !outcomes.length) return fail(400, { skillChipError: 'The 3-piece talent requires a name, description, mechanic branch, and outcome.' }); + if (outcomes.some((outcome) => outcome.variants.maximum.value < outcome.variants.minimum.value)) return fail(400, { skillChipError: 'Every talent outcome maximum must be at least its minimum.' }); const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1])); - if (placeholders.some((index) => index < 1 || index > effects.length)) return fail(400, { skillChipError: 'Description placeholders must reference configured talent effects.' }); - const database = await getDatabase(); + if (placeholders.some((index) => !numberedOutcomes.some((outcome) => outcome.descriptionSlot === index))) return fail(400, { skillChipError: 'Description placeholders must reference configured numbered outcomes.' }); + if (descriptionTemplate.includes('{duration}') && durationSeconds === null) return fail(400, { skillChipError: 'The description uses {duration}, but no expiring state pool is defined.' }); + if (descriptionTemplate.includes('{max_stacks}') && !maxStacks) return fail(400, { skillChipError: 'The description uses {max_stacks}, but no state pool maximum is defined.' }); if (!await (await database.prepare('SELECT id FROM specialization_abilities WHERE id=? AND active=1')).get(abilityId)) { return fail(400, { skillChipError: 'Choose an active specialization ability.' }); } @@ -219,39 +228,56 @@ export const actions: Actions = { if (await (await database.prepare('SELECT id FROM skill_chip_sets WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) return fail(409, { skillChipError: 'That Skill Chip set already exists.' }); const existingTalent = id ? await (await database.prepare('SELECT id FROM skill_chip_talents WHERE skill_chip_set_id=?')).get(id) : null; if (await (await database.prepare('SELECT id FROM skill_chip_talents WHERE name=? COLLATE NOCASE AND id<>?')).get(talentName, existingTalent?.id ?? 'new')) return fail(409, { skillChipError: 'That Skill Chip talent name is already in use.' }); - const allStatIds = [...new Set([twoPieceStatId, ...primaryStatIds, ...secondaryStatIds, ...effects.map((effect) => effect.statId)])]; + const outcomeStatIds = outcomes.map((effect) => effect.attributeId).filter((value): value is string => Boolean(value)); + const allStatIds = [...new Set([twoPieceStatId, ...primaryStatIds, ...secondaryStatIds, ...outcomeStatIds])]; const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${allStatIds.map(() => '?').join(',')})`)).get(...allStatIds); if (Number(validStats?.count) !== allStatIds.length) return fail(400, { skillChipError: 'One or more selected stats are unavailable.' }); const rollStatIds = [...primaryStatIds, ...secondaryStatIds]; const validRollStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND skill_chip_min IS NOT NULL AND skill_chip_max IS NOT NULL AND id IN (${rollStatIds.map(() => '?').join(',')})`)).get(...rollStatIds); if (Number(validRollStats?.count) !== rollStatIds.length) return fail(400, { skillChipError: 'Every primary and secondary option requires a configured Skill Chip range.' }); - const validTriggers = await (await database.prepare(`SELECT COUNT(*) AS count FROM talent_triggers WHERE active=1 AND id IN (${triggerIds.map(() => '?').join(',')})`)).get(...triggerIds); - if (Number(validTriggers?.count) !== triggerIds.length) return fail(400, { skillChipError: 'One or more selected triggers are unavailable.' }); const setId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(); const talentId = String(existingTalent?.id ?? randomUUID()); const now = new Date().toISOString(); const before = id ? await snapshot(id) : null; + if (id && !before?.record) return fail(404, { skillChipError: 'Skill Chip set not found.' }); const previousAbilityId = String(before?.record?.specialization_ability_id ?? ''); - if (id) { - if (!await auditRecord('skill_chip_sets', id)) return fail(404, { skillChipError: 'Skill Chip set not found.' }); - await (await database.prepare('UPDATE skill_chip_sets SET name=?,specialization_ability_id=?,active=1,updated_at=? WHERE id=?')).run(name, abilityId, now, id); - } else await (await database.prepare('INSERT INTO skill_chip_sets(id,name,specialization_ability_id,created_at,updated_at) VALUES(?,?,?,?,?)')).run(setId, name, abilityId, now, now); - await (await database.prepare(`INSERT INTO skill_chip_two_piece_bonuses(id,skill_chip_set_id,stat_attribute_id,min_value,max_value) VALUES(?,?,?,?,?) - ON CONFLICT(skill_chip_set_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,min_value=excluded.min_value,max_value=excluded.max_value`)).run(randomUUID(), setId, twoPieceStatId, twoPieceMin, twoPieceMax); - await (await database.prepare('DELETE FROM skill_chip_primary_options WHERE skill_chip_set_id=?')).run(setId); - for (const statId of primaryStatIds) await (await database.prepare('INSERT INTO skill_chip_primary_options(skill_chip_set_id,stat_attribute_id) VALUES(?,?)')).run(setId, statId); - await (await database.prepare('DELETE FROM skill_chip_secondary_options WHERE skill_chip_set_id=?')).run(setId); - for (const statId of secondaryStatIds) await (await database.prepare('INSERT INTO skill_chip_secondary_options(skill_chip_set_id,stat_attribute_id) VALUES(?,?)')).run(setId, statId); - await (await database.prepare(`INSERT INTO skill_chip_talents(id,skill_chip_set_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?) - ON CONFLICT(skill_chip_set_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)).run(talentId, setId, talentName, descriptionTemplate, now, now); - await (await database.prepare('DELETE FROM skill_chip_talent_triggers WHERE talent_id=?')).run(talentId); - for (const triggerId of triggerIds) await (await database.prepare('INSERT INTO skill_chip_talent_triggers(talent_id,trigger_id) VALUES(?,?)')).run(talentId, triggerId); - await (await database.prepare('DELETE FROM skill_chip_talent_effects WHERE talent_id=?')).run(talentId); - for (const effect of effects) await (await database.prepare('INSERT INTO skill_chip_talent_effects(id,talent_id,stat_attribute_id,min_value,max_value,sort_order) VALUES(?,?,?,?,?,?)')).run(randomUUID(), talentId, effect.statId, effect.minValue, effect.maxValue, effect.sortOrder); - await applyIconMutation(database, 'skill_chip', setId, iconMutation, operator.id); - await refreshAbilityFocuses(database, abilityId); - if (previousAbilityId && previousAbilityId !== abilityId) await refreshAbilityFocuses(database, previousAbilityId); - await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'skill_chip.updated' : 'skill_chip.created', entityId: setId, before, after: await snapshot(setId) }); + await database.exec('BEGIN'); + try { + if (id) { + await (await database.prepare('UPDATE skill_chip_sets SET name=?,specialization_ability_id=?,active=1,updated_at=? WHERE id=?')).run(name, abilityId, now, id); + } else await (await database.prepare('INSERT INTO skill_chip_sets(id,name,specialization_ability_id,created_at,updated_at) VALUES(?,?,?,?,?)')).run(setId, name, abilityId, now, now); + await (await database.prepare(`INSERT INTO skill_chip_two_piece_bonuses(id,skill_chip_set_id,stat_attribute_id,min_value,max_value) VALUES(?,?,?,?,?) + ON CONFLICT(skill_chip_set_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,min_value=excluded.min_value,max_value=excluded.max_value`)).run(randomUUID(), setId, twoPieceStatId, twoPieceMin, twoPieceMax); + await (await database.prepare('DELETE FROM skill_chip_primary_options WHERE skill_chip_set_id=?')).run(setId); + for (const statId of primaryStatIds) await (await database.prepare('INSERT INTO skill_chip_primary_options(skill_chip_set_id,stat_attribute_id) VALUES(?,?)')).run(setId, statId); + await (await database.prepare('DELETE FROM skill_chip_secondary_options WHERE skill_chip_set_id=?')).run(setId); + for (const statId of secondaryStatIds) await (await database.prepare('INSERT INTO skill_chip_secondary_options(skill_chip_set_id,stat_attribute_id) VALUES(?,?)')).run(setId, statId); + await (await database.prepare(`INSERT INTO skill_chip_talents(id,skill_chip_set_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?) + ON CONFLICT(skill_chip_set_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)).run(talentId, setId, talentName, descriptionTemplate, now, now); + await (await database.prepare('DELETE FROM skill_chip_talent_effects WHERE talent_id=?')).run(talentId); + let compatibilitySortOrder = 0; + for (const outcome of numberedOutcomes) { + if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId) continue; + compatibilitySortOrder += 1; + await (await database.prepare('INSERT INTO skill_chip_talent_effects(id,talent_id,stat_attribute_id,min_value,max_value,sort_order) VALUES(?,?,?,?,?,?)')).run( + randomUUID(), + talentId, + outcome.attributeId, + outcome.variants.minimum.value, + outcome.variants.maximum.value, + compatibilitySortOrder + ); + } + await saveConditionalRuleInTransaction(database, 'skill_chip_talent', talentId, conditionalRule); + await applyIconMutation(database, 'skill_chip', setId, iconMutation, operator.id); + await refreshAbilityFocuses(database, abilityId); + if (previousAbilityId && previousAbilityId !== abilityId) await refreshAbilityFocuses(database, previousAbilityId); + await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'skill_chip.updated' : 'skill_chip.created', entityId: setId, before, after: await snapshot(setId) }); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } return { skillChipSaved: true, skillChipMode: id ? 'edit' : 'add', resultToken: randomUUID() }; }, archive: async ({ request, cookies }) => { diff --git a/src/routes/isac/skillchips/+page.svelte b/src/routes/isac/skillchips/+page.svelte index b249d7e..eeb76e3 100644 --- a/src/routes/isac/skillchips/+page.svelte +++ b/src/routes/isac/skillchips/+page.svelte @@ -8,10 +8,19 @@ import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte'; import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; + import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte'; + import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor'; let { data, form } = $props(); - type TalentEffect = { stat_attribute_id: string; min_value: number | string; max_value: number | string }; - const blankTalent = () => ({ id: '', name: '', description_template: '', triggerIds: [], effects: [{ stat_attribute_id: '', min_value: '', max_value: '' }] }); + const talentValueColumns = [ + { key: 'minimum', variantKey: 'minimum', label: 'MINIMUM' }, + { key: 'maximum', variantKey: 'maximum', label: 'MAXIMUM' } + ]; + const blankRule = (): ConditionalMechanicDraftV1 => ({ + version:1,simulationReadiness:'captured_only',scenarioInputs:[],pools:[], + clauses:[{key:'primary',label:'Primary',event:'skill_hit',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'assigned_ability',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{minimum:{value:0},maximum:{value:0}}}]}] + }); + const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() }); const blank = () => ({ id: '', name: '', abilityId: '', iconUrl: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, primaryStatIds: [], secondaryStatIds: [], talent: blankTalent() }); let editing = $state(null); let highEndCount = $state('0'); @@ -33,9 +42,20 @@ return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`; }; const scaled = (minimum: number | string, maximum: number | string) => Number(minimum) + (Number(maximum) - Number(minimum)) * Number(highEndCount) / 3; - const talentPreview = () => editing.talent.description_template.replace(/\{(\d+)\}/g, (placeholder: string, rawIndex: string) => { - const effect = editing.talent.effects[Number(rawIndex) - 1]; - return effect ? format(scaled(effect.min_value, effect.max_value), effect.stat_attribute_id) : placeholder; + const previewOutcomes = () => editing.talent.conditionalRule.clauses.flatMap((clause: any) => clause.outcomes) + .filter((outcome: any) => outcome.descriptionSlot) + .sort((left: any, right: any) => left.descriptionSlot - right.descriptionSlot); + const talentPreview = () => editing.talent.description_template.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => { + const duration = editing.talent.conditionalRule.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds; + const maxStacks = editing.talent.conditionalRule.pools.length ? Math.max(...editing.talent.conditionalRule.pools.map((pool: any) => pool.max ?? 1)) : null; + if (expression === 'duration') return duration == null ? placeholder : `${duration} seconds`; + if (expression === 'max_stacks') return maxStacks == null ? placeholder : `${maxStacks} stacks`; + const outcome = previewOutcomes().find((item: any) => item.descriptionSlot === Number(expression)); + const minimum = outcome?.variants?.minimum?.value; + const maximum = outcome?.variants?.maximum?.value; + return outcome && minimum !== '' && maximum !== '' && minimum !== null && maximum !== null && minimum !== undefined && maximum !== undefined + ? format(scaled(minimum, maximum), outcome.attributeId) + : placeholder; }); const edit = (item: any) => { editing = { @@ -44,16 +64,10 @@ twoPiece: item.twoPiece ? { ...item.twoPiece } : { stat_attribute_id: '', min_value: '', max_value: '' }, primaryStatIds: [...item.primaryStatIds], secondaryStatIds: [...item.secondaryStatIds], - talent: item.talent ? { ...item.talent, triggerIds: [...item.talent.triggerIds], effects: item.talent.effects.map((effect: TalentEffect) => ({ ...effect })) } : blankTalent() + talent: item.talent ? { ...item.talent, conditionalRule: item.talent.conditionalRule ?? blankRule() } : blankTalent() }; highEndCount = '0'; }; - const addEffect = () => { - if (editing.talent.effects.length < 12) editing.talent.effects = [...editing.talent.effects, { stat_attribute_id: '', min_value: '', max_value: '' }]; - }; - const removeEffect = (index: number) => { - if (editing.talent.effects.length > 1) editing.talent.effects = editing.talent.effects.filter((_: TalentEffect, effectIndex: number) => effectIndex !== index); - }; $effect(() => { if (form?.skillChipSaved && form.resultToken && form.resultToken !== handledResultToken) { handledResultToken = form.resultToken; @@ -81,7 +95,7 @@

{editing.id ? 'MODIFY CHIP SET' : 'NEW CHIP SET'}

{editing.id ? editing.name : 'SKILL CHIP DEFINITION'}

- ({ statId: effect.stat_attribute_id, minValue: effect.min_value, maxValue: effect.max_value })))}/> +
SUPPORTED QUALITY
SUPERIORHIGH-END
Only these qualities participate in chip scaling.
SPECIALIZATION ABILITYEXCLUSIVE RELATIONSHIP · THREE ACTIVE SKILL CHIPS PER ABILITY
{editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 ASSIGNED` : 'REQUIRED'}
@@ -99,16 +113,19 @@
-
3-PIECE TALENTQUALITY-SCALED EFFECTS · SHARED TRIGGERS AND DESCRIPTION
{editing.talent.effects.length} EFFECT{editing.talent.effects.length === 1 ? '' : 'S'}
+
3-PIECE TALENTQUALITY-SCALED OUTCOMES · TYPED TRIGGER AND DESCRIPTION
{previewOutcomes().length} OUTCOME{previewOutcomes().length === 1 ? '' : 'S'}
{3 - Number(highEndCount)} SUPERIOR · {highEndCount} HIGH-END

{talentPreview() || 'Talent description preview'}

-
-
ACTIVATION TRIGGERSREUSABLE TRIGGER DEFINITIONS
{editing.talent.triggerIds.length}
-
TALENT EFFECTSMINIMUM TO MAXIMUM ACROSS QUALITY MIX
{#each editing.talent.effects as effect, index}
{index + 1}
{/each}
-
+
{#if form?.skillChipError}

{form.skillChipError}

{/if} -
{#if editing.id}{/if}
+
{#if editing.id}{/if}
{/if} @@ -119,7 +136,7 @@
QUALITY
{#each data.sets as item} -
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}
{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}EXCLUSIVE ABILITY
SUPHE{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}
{item.talent?.name ?? 'NOT CONFIGURED'}{item.talent?.effects.length ?? 0} SCALED EFFECTS
{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}{#if item.pending}{:else}{/if}
+
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}
{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}EXCLUSIVE ABILITY
SUPHE{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}
{item.talent?.name ?? 'NOT CONFIGURED'}{item.talent?.conditionalRule?.clauses?.length || item.pending ? 'MODELED · QUALITY SCALED' : 'CONFIG REQUIRED'}
{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}{#if item.pending}{:else}{/if}
{#if editing?.id === item.id}{@render skillChipEditor()}{/if} {:else}
NO SKILL CHIP SETS REGISTEREDAdd the first Superior and High-end Skill Chip definition.
{/each}
diff --git a/src/routes/isac/specializations/+page.server.ts b/src/routes/isac/specializations/+page.server.ts index 4f67da5..6024b23 100644 --- a/src/routes/isac/specializations/+page.server.ts +++ b/src/routes/isac/specializations/+page.server.ts @@ -1,5 +1,7 @@ import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; +import { mkdir, unlink, writeFile } from 'node:fs/promises'; +import path from 'node:path'; import type { Database } from '@tursodatabase/database'; import type { Actions, PageServerLoad } from './$types'; import { requireDataView, requirePermission } from '$lib/server/auth/authorization'; @@ -10,15 +12,26 @@ import { deserializeProposal, getPendingEntities } from '$lib/server/data-modera import { auditRecord, recordIsacAudit } from '$lib/server/audit'; import { applyIconMutation, + gameDataIconStorageDirectory, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, - readIconMutation + readGameDataIcon, + readIconMutation, + type GameDataIconType } from '$lib/server/game-data-icons'; import { refreshSpecializationFocusReady, refreshSpecializationFocuses } from '$lib/server/specialization-readiness'; +import { + conditionalRuleSnapshot, + loadConditionalRuleDraft, + parseConditionalRulePayload, + saveConditionalRuleInTransaction, +} from '$lib/server/conditional-effects'; +import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor'; +import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes'; const PRIMARY_ATTRIBUTES = ['Firepower', 'Toughness', 'Engineering'] as const; const RANK_TWO_RESOURCES = new Set([ @@ -42,20 +55,9 @@ type RankEffectInput = AbilityEffectInput & { abilityId?: string; }; -type RankCombatModifierInput = { - statId?: string; - valuePerStack?: number | string | null; - unit?: string; -}; - type RankCombatEffectInput = { effectId?: string; - triggerCount?: number | string; - triggerWindowSeconds?: number | string; - durationSeconds?: number | string; - maxStacks?: number | string; - stacksPerTrigger?: number | string; - modifiers?: RankCombatModifierInput[]; + conditionalRule?: unknown; }; type RankInput = { @@ -82,6 +84,36 @@ const parseJsonArray = (value: unknown): T[] => { } }; +const parsedJsonObject = (value: unknown) => { + try { + const parsed = JSON.parse(String(value ?? '{}')); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : {}; + } catch { + return {}; + } +}; + +async function restoreIconFile( + type: GameDataIconType, + entityId: string, + previousIcon: Buffer | null +) { + const iconPath = path.join(gameDataIconStorageDirectory(), `${type}-${entityId}.webp`); + if (previousIcon) { + await mkdir(gameDataIconStorageDirectory(), { recursive: true }); + await writeFile(iconPath, previousIcon, { flag: 'w', mode: 0o600 }); + return; + } + await unlink(iconPath).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error; + }); +} + +const mechanicOutcomes = (rule: ConditionalMechanicDraftV1) => + rule.clauses.flatMap((clause) => clause.outcomes); + async function specializationSnapshot(database: Database, id: string) { const record = await auditRecord('specializations', id); const focusIds = (await (await database.prepare( @@ -112,6 +144,9 @@ async function focusSnapshot(database: Database, id: string) { async function abilitySnapshot(database: Database, id: string) { return { record: await auditRecord('specialization_abilities', id), + attack: await (await database.prepare( + 'SELECT * FROM specialization_ability_attacks WHERE ability_id=?' + )).get(id) ?? null, effects: await (await database.prepare( 'SELECT * FROM specialization_ability_effects WHERE ability_id=? ORDER BY effect_order' )).all(id), @@ -123,6 +158,9 @@ async function nodeSnapshot(database: Database, id: string) { const combatEffects = await (await database.prepare( 'SELECT * FROM specialization_tree_rank_combat_effects WHERE node_id=? ORDER BY rank,effect_order' )).all(id); + const conditionalRules = await Promise.all(combatEffects.map((effect) => + conditionalRuleSnapshot(database, 'specialization_tree_rank', String(effect.id)) + )); return { record: await auditRecord('specialization_tree_nodes', id), ranks: await (await database.prepare( @@ -140,6 +178,7 @@ async function nodeSnapshot(database: Database, id: string) { JOIN specialization_tree_rank_combat_effects attachment ON attachment.id=modifier.rank_combat_effect_id WHERE attachment.node_id=? ORDER BY attachment.rank,attachment.effect_order,modifier.modifier_order` )).all(id) : [], + conditionalRules, icon: await gameDataIconMetadata(database, 'specialization_node', id) }; } @@ -199,7 +238,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const pending = await getPendingEntities(user.id, [ { module: 'specializations', action: 'saveSpecialization', kind: 'specialization', fields: ['description'] }, { module: 'specializations', action: 'saveFocus', kind: 'focus', fields: ['specializationId', 'description', 'primaryAttribute', 'displayOrder'] }, - { module: 'specializations', action: 'saveAbility', kind: 'ability', fields: ['specializationId', 'description', 'durationSeconds', 'cooldownSeconds', 'effectsJson'] }, + { module: 'specializations', action: 'saveAbility', kind: 'ability', fields: ['specializationId', 'description', 'durationSeconds', 'cooldownSeconds', 'effectsJson', 'attackJson'] }, { module: 'specializations', action: 'saveCombatEffect', kind: 'combatEffect', fields: ['specializationId', 'descriptionTemplate'] }, { module: 'specializations', action: 'saveNode', kind: 'node', fields: ['focusId', 'descriptionTemplate', 'rowNumber', 'columnNumber', 'maxRank', 'ranksJson'] } ]); @@ -223,12 +262,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const pendingOnPage = pagination.page === 1 ? pendingSpecializations.slice(0, pagination.perPage) : []; const databaseOffset = Math.max(0, pagination.offset - pendingSpecializations.length); const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length); - const [specializations, focuses, abilities, abilityEffects, focusAbilities, nodes, nodeRanks, nodeEffects, nodeCosts, combatEffects, nodeCombatEffects, nodeCombatModifiers, resources, rankProfiles, progression, attributes, chipCounts, icons, summary] = await Promise.all([ + const [specializations, focuses, abilities, abilityAttacks, abilityEffects, focusAbilities, nodes, nodeRanks, nodeEffects, nodeCosts, combatEffects, nodeCombatEffects, nodeCombatModifiers, resources, rankProfiles, progression, attributes, chipCounts, icons, summary] = await Promise.all([ databaseLimit ? (await database.prepare( `SELECT * FROM specializations ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?` )).all(...args, databaseLimit, databaseOffset) : [], (await database.prepare('SELECT * FROM specialization_focuses ORDER BY specialization_id,display_order,name COLLATE NOCASE')).all(), (await database.prepare('SELECT * FROM specialization_abilities ORDER BY specialization_id,active DESC,name COLLATE NOCASE')).all(), + (await database.prepare('SELECT * FROM specialization_ability_attacks')).all(), (await database.prepare('SELECT * FROM specialization_ability_effects ORDER BY ability_id,effect_order')).all(), (await database.prepare('SELECT * FROM specialization_focus_abilities ORDER BY focus_id,ability_slot')).all(), (await database.prepare('SELECT * FROM specialization_tree_nodes ORDER BY focus_id,row_number,column_number')).all(), @@ -265,6 +305,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const pendingAbilities = pending.filter((item) => item.kind === 'ability'); const pendingCombatEffects = pending.filter((item) => item.kind === 'combatEffect'); const pendingNodes = pending.filter((item) => item.kind === 'node'); + const conditionalDrafts = new Map(await Promise.all(nodeCombatEffects.map(async (effect) => [ + String(effect.id), + await loadConditionalRuleDraft(database, 'specialization_tree_rank', String(effect.id), ['base']) + ] as const))); const iconUrl = (type: 'specialization' | 'specialization_ability' | 'specialization_node', id: string) => { const icon = icons.find((item) => item.entity_type === type && item.entity_id === id); return icon ? gameDataIconUrl(type, id, icon.updated_at) : ''; @@ -291,6 +335,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { specialization_id: item.specializationId, duration_seconds: item.durationSeconds || null, cooldown_seconds: item.cooldownSeconds || null, + attack: item.attackJson ? JSON.parse(String(item.attackJson)) : null, effects: parseJsonArray(item.effectsJson), active: 1, pending: true, @@ -299,6 +344,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { })), ...abilities.map((ability) => ({ ...ability, + attack: abilityAttacks.find((attack) => attack.ability_id === ability.id) ?? null, effects: abilityEffects.filter((effect) => effect.ability_id === ability.id), skillChipCount: Number(chipCounts.find((item) => item.specialization_ability_id === ability.id)?.count ?? 0), iconUrl: iconUrl('specialization_ability', String(ability.id)) @@ -326,7 +372,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => { .filter((effect) => effect.node_id === node.id && Number(effect.rank) === Number(rank.rank)) .map((effect) => ({ ...effect, - modifiers: nodeCombatModifiers.filter((modifier) => modifier.rank_combat_effect_id === effect.id) + modifiers: nodeCombatModifiers.filter((modifier) => modifier.rank_combat_effect_id === effect.id), + conditionalRule: conditionalDrafts.get(String(effect.id)) ?? null })), costs: Object.fromEntries(nodeCosts.filter((cost) => cost.node_id === node.id && Number(cost.rank) === Number(rank.rank)) .map((cost) => [String(cost.resource_id), Number(cost.quantity)])) @@ -553,6 +600,37 @@ export const actions: Actions = { value: effect.value === '' || effect.value === null || effect.value === undefined ? null : Number(effect.value), unit: cleanName(effect.unit) })); + let attack: null | { + baseDamage: number; + damageType: string; + delivery: string; + hitsPerActivation: number; + tickIntervalSeconds: number | null; + damageDurationSeconds: number | null; + castSeconds: number; + targetCap: number; + chargeCount: number; + chargePolicy: string; + } = null; + try { + const raw = JSON.parse(String(form.get('attackJson') ?? '{"enabled":false}')); + if (raw?.enabled) { + attack = { + baseDamage: Number(raw.baseDamage), + damageType: String(raw.damageType ?? ''), + delivery: String(raw.delivery ?? ''), + hitsPerActivation: Number(raw.hitsPerActivation ?? 1), + tickIntervalSeconds: raw.delivery === 'periodic' ? Number(raw.tickIntervalSeconds) : null, + damageDurationSeconds: raw.delivery === 'periodic' ? Number(raw.damageDurationSeconds) : null, + castSeconds: Number(raw.castSeconds ?? 0), + targetCap: Number(raw.targetCap ?? 1), + chargeCount: raw.chargePolicy === 'none' ? 1 : Number(raw.chargeCount ?? 1), + chargePolicy: String(raw.chargePolicy ?? 'none') + }; + } + } catch { + return fail(400, { abilityError: 'The ability attack profile could not be read.' }); + } if (name.length < 2 || name.length > 100) return fail(400, { abilityError: 'Ability names must be 2–100 characters.' }); if (description.length < 3 || description.length > 2400) return fail(400, { abilityError: 'Ability descriptions must be 3–2,400 characters.' }); if (Number.isNaN(durationSeconds) || Number.isNaN(cooldownSeconds)) { @@ -568,6 +646,20 @@ export const actions: Actions = { if (effects.some((effect) => effect.type === 'Custom' && effect.label.length < 2)) { return fail(400, { abilityError: 'Custom effects require a concise label because they have no Attribute name to use.' }); } + if (attack && ( + !Number.isFinite(attack.baseDamage) || attack.baseDamage <= 0 + || !['Blast', 'Pierce', 'Shredding'].includes(attack.damageType) + || !['direct', 'periodic'].includes(attack.delivery) + || !Number.isInteger(attack.hitsPerActivation) || attack.hitsPerActivation <= 0 + || !Number.isFinite(attack.castSeconds) || attack.castSeconds < 0 + || !Number.isInteger(attack.targetCap) || attack.targetCap <= 0 + || !['none', 'independent', 'full_set'].includes(attack.chargePolicy) + || !Number.isInteger(attack.chargeCount) || attack.chargeCount <= 0 + || (attack.delivery === 'periodic' && ( + !Number.isFinite(attack.tickIntervalSeconds) || Number(attack.tickIntervalSeconds) <= 0 + || !Number.isFinite(attack.damageDurationSeconds) || Number(attack.damageDurationSeconds) <= 0 + )) + )) return fail(400, { abilityError: 'Provide a complete and valid intrinsic attack profile.' }); const database = await getDatabase(); if (id) { const existing = await (await database.prepare( @@ -611,6 +703,10 @@ export const actions: Actions = { } const abilityId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(); const before = id ? await abilitySnapshot(database, id) : null; + const iconChanged = Boolean(iconMutation.output || iconMutation.remove); + const previousIcon = iconChanged + ? await readGameDataIcon(database, 'specialization_ability', abilityId) + : null; const now = new Date().toISOString(); await database.exec('BEGIN'); try { @@ -630,21 +726,55 @@ export const actions: Actions = { (id,ability_id,effect_order,stat_attribute_id,label,value,unit) VALUES(?,?,?,?,?,?,?)`)) .run(randomUUID(), abilityId, index + 1, effect.statId || null, effect.label, effect.value, effect.unit || null); } + if (attack) { + await (await database.prepare(`INSERT INTO specialization_ability_attacks + (ability_id,base_damage,damage_type,delivery,hits_per_activation,tick_interval_seconds, + damage_duration_seconds,cast_seconds,target_cap,charge_count,charge_policy,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(ability_id) DO UPDATE SET + base_damage=excluded.base_damage, + damage_type=excluded.damage_type, + delivery=excluded.delivery, + hits_per_activation=excluded.hits_per_activation, + tick_interval_seconds=excluded.tick_interval_seconds, + damage_duration_seconds=excluded.damage_duration_seconds, + cast_seconds=excluded.cast_seconds, + target_cap=excluded.target_cap, + charge_count=excluded.charge_count, + charge_policy=excluded.charge_policy, + updated_at=excluded.updated_at`)).run( + abilityId, + attack.baseDamage, + attack.damageType, + attack.delivery, + attack.hitsPerActivation, + attack.tickIntervalSeconds, + attack.damageDurationSeconds, + attack.castSeconds, + attack.targetCap, + attack.chargeCount, + attack.chargePolicy, + now + ); + } else { + await (await database.prepare('DELETE FROM specialization_ability_attacks WHERE ability_id=?')).run(abilityId); + } + await applyIconMutation(database, 'specialization_ability', abilityId, iconMutation, operator.id); + await refreshSpecializationFocuses(database, specializationId); + await recordIsacAudit({ + actorId: operator.id, + category: 'game_data', + action: id ? 'specialization_ability.updated' : 'specialization_ability.created', + entityId: abilityId, + before, + after: await abilitySnapshot(database, abilityId) + }); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); + if (iconChanged) await restoreIconFile('specialization_ability', abilityId, previousIcon); throw error; } - await applyIconMutation(database, 'specialization_ability', abilityId, iconMutation, operator.id); - await refreshSpecializationFocuses(database, specializationId); - await recordIsacAudit({ - actorId: operator.id, - category: 'game_data', - action: id ? 'specialization_ability.updated' : 'specialization_ability.created', - entityId: abilityId, - before, - after: await abilitySnapshot(database, abilityId) - }); return { abilitySaved: true, abilityMode: id ? 'edit' : 'add', resultToken: randomUUID() }; }, @@ -768,16 +898,7 @@ export const actions: Actions = { })) : [], combatEffects: Array.isArray(rank.combatEffects) ? rank.combatEffects.map((effect) => ({ effectId: String(effect.effectId ?? ''), - triggerCount: Number(effect.triggerCount), - triggerWindowSeconds: Number(effect.triggerWindowSeconds), - durationSeconds: Number(effect.durationSeconds), - maxStacks: Number(effect.maxStacks), - stacksPerTrigger: Number(effect.stacksPerTrigger ?? 1), - modifiers: Array.isArray(effect.modifiers) ? effect.modifiers.map((modifier) => ({ - statId: String(modifier.statId ?? ''), - valuePerStack: Number(modifier.valuePerStack), - unit: cleanName(modifier.unit) - })) : [] + conditionalRule: effect.conditionalRule })) : [], costs: Object.fromEntries(Object.entries(rank.costs ?? {}).map(([resourceId, quantity]) => [resourceId, Number(quantity)])) })); @@ -806,16 +927,9 @@ export const actions: Actions = { return fail(400, { nodeError: 'Every rank effect requires a valid type, target, label, and optional numeric value.' }); } if (allCombatEffects.length > 18 || allCombatEffects.some((effect) => - !effect.effectId - || !Number.isInteger(effect.triggerCount) || effect.triggerCount < 1 || effect.triggerCount > 1000 - || !Number.isFinite(effect.triggerWindowSeconds) || effect.triggerWindowSeconds <= 0 || effect.triggerWindowSeconds > 3600 - || !Number.isFinite(effect.durationSeconds) || effect.durationSeconds <= 0 || effect.durationSeconds > 86400 - || !Number.isInteger(effect.maxStacks) || effect.maxStacks < 1 || effect.maxStacks > 1000 - || !Number.isInteger(effect.stacksPerTrigger) || effect.stacksPerTrigger < 1 || effect.stacksPerTrigger > effect.maxStacks - || effect.modifiers.length < 1 || effect.modifiers.length > 12 - || effect.modifiers.some((modifier) => !modifier.statId || !Number.isFinite(modifier.valuePerStack) || !EFFECT_UNITS.has(modifier.unit)) + !effect.effectId || !effect.conditionalRule )) { - return fail(400, { nodeError: 'Every conditional effect requires a valid trigger, stack lifecycle, and at least one Attribute modifier.' }); + return fail(400, { nodeError: 'Every conditional effect requires a shared effect and a complete calculation rule.' }); } const rankOneCosts = ranks[0]?.costs ?? {}; if (Object.values(rankOneCosts).some((quantity) => !Number.isInteger(quantity) || quantity !== 0)) { @@ -842,6 +956,26 @@ export const actions: Actions = { } } const database = await getDatabase(); + const parsedCombatRules = new Map<(typeof allCombatEffects)[number], ConditionalMechanicDraftV1>(); + try { + const activeAttributes = await (await database.prepare('SELECT id FROM stat_attributes WHERE active=1')).all(); + for (const effect of allCombatEffects) { + parsedCombatRules.set(effect, parseConditionalRulePayload( + JSON.stringify(effect.conditionalRule), + [{ key: 'base', variantKey: 'base' }], + { + allowedAttributeIds: new Set(activeAttributes.map((row) => String(row.id))), + allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)) + } + )); + } + } catch (error) { + return fail(400, { + nodeError: error instanceof Error + ? error.message + : 'One or more conditional mechanics are invalid.' + }); + } if (id) { const existing = await (await database.prepare( 'SELECT focus_id FROM specialization_tree_nodes WHERE id=?' @@ -858,7 +992,9 @@ export const actions: Actions = { if (!await validateAttributeIds(database, allEffects.map((effect) => effect.statId))) { return fail(400, { nodeError: 'One or more effect Attributes are unavailable.' }); } - if (!await validateAttributeIds(database, allCombatEffects.flatMap((effect) => effect.modifiers.map((modifier) => modifier.statId)))) { + const conditionalAttributeIds = [...parsedCombatRules.values()] + .flatMap((rule) => mechanicOutcomes(rule).map((outcome) => outcome.attributeId).filter((id): id is string => Boolean(id))); + if (!await validateAttributeIds(database, conditionalAttributeIds)) { return fail(400, { nodeError: 'One or more conditional-effect Attributes are unavailable.' }); } if (!await validateAbilityIds(database, String(focus.specialization_id), allEffects.map((effect) => effect.abilityId))) { @@ -888,6 +1024,10 @@ export const actions: Actions = { } const nodeId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(); const before = id ? await nodeSnapshot(database, id) : null; + const iconChanged = Boolean(iconMutation.output || iconMutation.remove); + const previousIcon = iconChanged + ? await readGameDataIcon(database, 'specialization_node', nodeId) + : null; const now = new Date().toISOString(); await database.exec('BEGIN'); try { @@ -901,6 +1041,14 @@ export const actions: Actions = { VALUES(?,?,?,?,?,?,?,?,?)`)) .run(nodeId, focusId, name, descriptionTemplate, rowNumber, columnNumber, maxRank, now, now); } + const replacedCombatIds = (await (await database.prepare( + 'SELECT id FROM specialization_tree_rank_combat_effects WHERE node_id=?' + )).all(nodeId)).map((row) => String(row.id)); + for (const attachmentId of replacedCombatIds) { + await (await database.prepare( + "DELETE FROM conditional_effect_rules WHERE source_type='specialization_tree_rank' AND source_id=?" + )).run(attachmentId); + } await (await database.prepare('DELETE FROM specialization_tree_node_ranks WHERE node_id=?')).run(nodeId); for (const rank of ranks) { await (await database.prepare( @@ -915,18 +1063,57 @@ export const actions: Actions = { } for (const [index, effect] of rank.combatEffects.entries()) { const attachmentId = randomUUID(); + const conditionalRule = parsedCombatRules.get(effect); + if (!conditionalRule) throw new Error('A validated conditional mechanic was not available during save.'); + const primaryClause = conditionalRule.clauses[0]; + const reducer = primaryClause?.reducer; + const legacyTriggerCount = Number( + reducer && 'threshold' in reducer ? reducer.threshold : 1 + ); + const legacyWindowSeconds = Number( + reducer && 'windowSeconds' in reducer + ? reducer.windowSeconds + : reducer?.kind === 'duration' ? reducer.seconds : 1 + ); + const legacyPool = conditionalRule.pools[0]; await (await database.prepare(`INSERT INTO specialization_tree_rank_combat_effects (id,node_id,rank,combat_effect_id,effect_order,trigger_type,trigger_count,trigger_window_seconds, duration_seconds,max_stacks,stacks_per_trigger,counter_policy,refresh_policy,expiry_policy) VALUES(?,?,?,?,?,'enemy_hits_within_window',?,?,?,?,?,'reset_on_proc','refresh_on_stack_gain','clear_all')`)) - .run(attachmentId, nodeId, rank.rank, effect.effectId, index + 1, effect.triggerCount, - effect.triggerWindowSeconds, effect.durationSeconds, effect.maxStacks, effect.stacksPerTrigger); - for (const [modifierIndex, modifier] of effect.modifiers.entries()) { + .run( + attachmentId, + nodeId, + rank.rank, + effect.effectId, + index + 1, + Number.isInteger(legacyTriggerCount) && legacyTriggerCount > 0 ? legacyTriggerCount : 1, + Number.isFinite(legacyWindowSeconds) && legacyWindowSeconds > 0 ? legacyWindowSeconds : 1, + legacyPool?.expiresAfterSeconds ?? 0.001, + legacyPool?.max ?? 1, + 1 + ); + const legacyModifiers = mechanicOutcomes(conditionalRule).filter((outcome) => + outcome.kind === 'attribute_modifier' && outcome.attributeId + ); + for (const [modifierIndex, modifier] of legacyModifiers.entries()) { await (await database.prepare(`INSERT INTO specialization_tree_rank_combat_modifiers (id,rank_combat_effect_id,modifier_order,stat_attribute_id,value_per_stack,unit) VALUES(?,?,?,?,?,?)`)) - .run(randomUUID(), attachmentId, modifierIndex + 1, modifier.statId, modifier.valuePerStack, modifier.unit || null); + .run( + randomUUID(), + attachmentId, + modifierIndex + 1, + modifier.attributeId, + modifier.variants.base.value, + null + ); } + await saveConditionalRuleInTransaction( + database, + 'specialization_tree_rank', + attachmentId, + conditionalRule + ); } for (const [resourceId, quantity] of Object.entries(rank.costs)) { if (Number(quantity) > 0) { @@ -936,21 +1123,22 @@ export const actions: Actions = { } } } + await applyIconMutation(database, 'specialization_node', nodeId, iconMutation, operator.id); + const readiness = await refreshSpecializationFocusReady(database, focusId); + await recordIsacAudit({ + actorId: operator.id, + category: 'game_data', + action: id ? 'specialization_node.updated' : 'specialization_node.created', + entityId: nodeId, + before, + after: { ...(await nodeSnapshot(database, nodeId)), readiness } + }); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); + if (iconChanged) await restoreIconFile('specialization_node', nodeId, previousIcon); throw error; } - await applyIconMutation(database, 'specialization_node', nodeId, iconMutation, operator.id); - const readiness = await refreshSpecializationFocusReady(database, focusId); - await recordIsacAudit({ - actorId: operator.id, - category: 'game_data', - action: id ? 'specialization_node.updated' : 'specialization_node.created', - entityId: nodeId, - before, - after: { ...(await nodeSnapshot(database, nodeId)), readiness } - }); return { nodeSaved: true, nodeMode: id ? 'edit' : 'add', resultToken: randomUUID() }; }, @@ -1003,9 +1191,16 @@ export const actions: Actions = { const database = await getDatabase(); const ability = await (await database.prepare('SELECT specialization_id FROM specialization_abilities WHERE id=?')).get(id); const before = await abilitySnapshot(database, id); - await (await database.prepare('UPDATE specialization_abilities SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); - if (ability) await refreshSpecializationFocuses(database, String(ability.specialization_id)); - await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_ability.archived', entityId: id, before, after: await abilitySnapshot(database, id) }); + await database.exec('BEGIN'); + try { + await (await database.prepare('UPDATE specialization_abilities SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); + if (ability) await refreshSpecializationFocuses(database, String(ability.specialization_id)); + await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_ability.archived', entityId: id, before, after: await abilitySnapshot(database, id) }); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } return { abilityArchived: true, resultToken: randomUUID() }; }, @@ -1015,9 +1210,16 @@ export const actions: Actions = { const database = await getDatabase(); const ability = await (await database.prepare('SELECT specialization_id FROM specialization_abilities WHERE id=?')).get(id); const before = await abilitySnapshot(database, id); - await (await database.prepare('UPDATE specialization_abilities SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); - if (ability) await refreshSpecializationFocuses(database, String(ability.specialization_id)); - await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_ability.unarchived', entityId: id, before, after: await abilitySnapshot(database, id) }); + await database.exec('BEGIN'); + try { + await (await database.prepare('UPDATE specialization_abilities SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); + if (ability) await refreshSpecializationFocuses(database, String(ability.specialization_id)); + await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_ability.unarchived', entityId: id, before, after: await abilitySnapshot(database, id) }); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } return { abilityUnarchived: true, resultToken: randomUUID() }; }, @@ -1027,9 +1229,16 @@ export const actions: Actions = { const database = await getDatabase(); const node = await (await database.prepare('SELECT focus_id FROM specialization_tree_nodes WHERE id=?')).get(id); const before = await nodeSnapshot(database, id); - await (await database.prepare('UPDATE specialization_tree_nodes SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); - const readiness = node ? await refreshSpecializationFocusReady(database, String(node.focus_id)) : null; - await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_node.archived', entityId: id, before, after: { ...(await nodeSnapshot(database, id)), readiness } }); + await database.exec('BEGIN'); + try { + await (await database.prepare('UPDATE specialization_tree_nodes SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); + const readiness = node ? await refreshSpecializationFocusReady(database, String(node.focus_id)) : null; + await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_node.archived', entityId: id, before, after: { ...(await nodeSnapshot(database, id)), readiness } }); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } return { nodeArchived: true, resultToken: randomUUID() }; }, @@ -1039,9 +1248,16 @@ export const actions: Actions = { const database = await getDatabase(); const node = await (await database.prepare('SELECT focus_id FROM specialization_tree_nodes WHERE id=?')).get(id); const before = await nodeSnapshot(database, id); - await (await database.prepare('UPDATE specialization_tree_nodes SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); - const readiness = node ? await refreshSpecializationFocusReady(database, String(node.focus_id)) : null; - await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_node.unarchived', entityId: id, before, after: { ...(await nodeSnapshot(database, id)), readiness } }); + await database.exec('BEGIN'); + try { + await (await database.prepare('UPDATE specialization_tree_nodes SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); + const readiness = node ? await refreshSpecializationFocusReady(database, String(node.focus_id)) : null; + await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_node.unarchived', entityId: id, before, after: { ...(await nodeSnapshot(database, id)), readiness } }); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } return { nodeUnarchived: true, resultToken: randomUUID() }; } }; diff --git a/src/routes/isac/specializations/+page.svelte b/src/routes/isac/specializations/+page.svelte index 47a5740..3030b47 100644 --- a/src/routes/isac/specializations/+page.svelte +++ b/src/routes/isac/specializations/+page.svelte @@ -10,6 +10,8 @@ import ItemIcon from '$lib/components/ItemIcon.svelte'; import GameIcon from '$lib/components/GameIcon.svelte'; import StructuredEffectEditor, { type StructuredEffect } from '$lib/components/StructuredEffectEditor.svelte'; + import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte'; + import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor'; let { data, form } = $props(); type SpecializationEditor = { id: string; name: string; description: string; active: number; iconUrl?: string }; @@ -29,6 +31,19 @@ description: string; durationSeconds: number | string; cooldownSeconds: number | string; + attack: { + enabled: boolean; + baseDamage: number | string; + damageType: string; + delivery: string; + hitsPerActivation: number | string; + tickIntervalSeconds: number | string; + damageDurationSeconds: number | string; + castSeconds: number | string; + targetCap: number | string; + chargeCount: number | string; + chargePolicy: string; + }; effects: StructuredEffect[]; active: number; iconUrl?: string; @@ -40,19 +55,9 @@ descriptionTemplate: string; active: number; }; - type RankCombatModifier = { - statId: string; - valuePerStack: number | string; - unit: string; - }; type RankCombatEffect = { effectId: string; - triggerCount: number | string; - triggerWindowSeconds: number | string; - durationSeconds: number | string; - maxStacks: number | string; - stacksPerTrigger: number | string; - modifiers: RankCombatModifier[]; + conditionalRule: ConditionalMechanicDraftV1; }; type RankEditor = { rank: number; @@ -80,28 +85,33 @@ { value: 'Engineering', label: 'ENGINEERING', icon: 'engineering' as const, iconOnly: true } ]; const rankOptions = [1, 2, 3].map((rank) => ({ value: String(rank), label: `RANK ${rank}` })); - const unitOptions = [ - { value: '', label: 'NONE' }, - { value: '%', label: '%' }, - { value: 's', label: 'SECONDS' }, - { value: 'Shots', label: 'SHOTS' }, - { value: 'm', label: 'METERS' } - ]; - const blankEffect = (): StructuredEffect => ({ type: 'Custom', statId: '', abilityId: '', label: '', value: '', unit: '' }); + const damageTypeOptions = ['Blast', 'Pierce', 'Shredding'].map((value) => ({ value, label: value.toUpperCase() })); + const deliveryOptions = [{ value: 'direct', label: 'DIRECT' }, { value: 'periodic', label: 'PERIODIC' }]; + const chargePolicyOptions = [{ value: 'none', label: 'NO CHARGES' }, { value: 'independent', label: 'INDEPENDENT' }, { value: 'full_set', label: 'FULL SET' }]; + const conditionalValueColumns = [{ key: 'base', variantKey: 'base', label: 'VALUE PER STACK' }]; const blankSpecialization = (): SpecializationEditor => ({ id: '', name: '', description: '', active: 1, iconUrl: '' }); const blankFocus = (specializationId = ''): FocusEditor => ({ id: '', specializationId, name: '', description: '', primaryAttribute: 'Firepower', displayOrder: 0, active: 1 }); const blankAbility = (specializationId = ''): AbilityEditor => ({ - id: '', specializationId, name: '', description: '', durationSeconds: '', cooldownSeconds: '', effects: [], active: 1, iconUrl: '' + id: '', specializationId, name: '', description: '', durationSeconds: '', cooldownSeconds: '', + attack: { + enabled: false, baseDamage: '', damageType: 'Blast', delivery: 'direct', + hitsPerActivation: 1, tickIntervalSeconds: '', damageDurationSeconds: '', + castSeconds: 0, targetCap: 1, chargeCount: 1, chargePolicy: 'none' + }, + effects: [], active: 1, iconUrl: '' }); const blankCombatEffect = (specializationId = ''): CombatEffectEditor => ({ id: '', specializationId, name: '', descriptionTemplate: '', active: 1 }); - const blankCombatModifier = (): RankCombatModifier => ({ statId: '', valuePerStack: '', unit: '%' }); + const blankConditionalRule = (): ConditionalMechanicDraftV1 => ({ + version:1,simulationReadiness:'captured_only',scenarioInputs:[],pools:[], + clauses:[{key:'primary',label:'Primary',event:'weapon_hit',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'self',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{base:{value:0}}}]}] + }); const blankRankCombatEffect = (): RankCombatEffect => ({ - effectId: '', triggerCount: 1, triggerWindowSeconds: 1, durationSeconds: 10, - maxStacks: 1, stacksPerTrigger: 1, modifiers: [blankCombatModifier()] + effectId: '', + conditionalRule: blankConditionalRule() }); const blankRank = (rank: number): RankEditor => ({ rank, @@ -124,6 +134,9 @@ value: effect.value ?? '', unit: String(effect.unit ?? '') }); + const setAbilityAttackEnabled = (enabled: boolean) => { + if (abilityEditing) abilityEditing.attack.enabled = enabled; + }; let specializationEditing = $state(null); let focusEditing = $state(null); @@ -216,6 +229,19 @@ description: item.description, durationSeconds: item.duration_seconds ?? '', cooldownSeconds: item.cooldown_seconds ?? '', + attack: item.attack ? { + enabled: true, + baseDamage: item.attack.base_damage, + damageType: item.attack.damage_type, + delivery: item.attack.delivery, + hitsPerActivation: item.attack.hits_per_activation, + tickIntervalSeconds: item.attack.tick_interval_seconds ?? '', + damageDurationSeconds: item.attack.damage_duration_seconds ?? '', + castSeconds: item.attack.cast_seconds, + targetCap: item.attack.target_cap, + chargeCount: item.attack.charge_count, + chargePolicy: item.attack.charge_policy + } : blankAbility(item.specialization_id).attack, effects: (item.effects ?? []).map(normalizedEffect), active: Number(item.active), iconUrl: item.iconUrl ?? '' @@ -248,16 +274,7 @@ effects: (rank.effects ?? []).map(normalizedEffect), combatEffects: (rank.combatEffects ?? []).map((effect: any) => ({ effectId: String(effect.combat_effect_id ?? effect.effectId ?? ''), - triggerCount: effect.trigger_count ?? effect.triggerCount ?? 1, - triggerWindowSeconds: effect.trigger_window_seconds ?? effect.triggerWindowSeconds ?? 1, - durationSeconds: effect.duration_seconds ?? effect.durationSeconds ?? 10, - maxStacks: effect.max_stacks ?? effect.maxStacks ?? 1, - stacksPerTrigger: effect.stacks_per_trigger ?? effect.stacksPerTrigger ?? 1, - modifiers: (effect.modifiers ?? []).map((modifier: any) => ({ - statId: String(modifier.stat_attribute_id ?? modifier.statId ?? ''), - valuePerStack: modifier.value_per_stack ?? modifier.valuePerStack ?? '', - unit: String(modifier.unit ?? '%') - })) + conditionalRule: effect.conditionalRule ?? blankConditionalRule() })), costs: { ...(rank.costs ?? {}) } })), @@ -280,13 +297,6 @@ const removeRankCombatEffect = (rank: RankEditor, index: number) => { rank.combatEffects = rank.combatEffects.filter((_, itemIndex) => itemIndex !== index); }; - const addCombatModifier = (effect: RankCombatEffect) => { - effect.modifiers = [...effect.modifiers, blankCombatModifier()]; - }; - const removeCombatModifier = (effect: RankCombatEffect, index: number) => { - if (effect.modifiers.length <= 1) return; - effect.modifiers = effect.modifiers.filter((_, itemIndex) => itemIndex !== index); - }; const openAbilityConfiguration = () => { if (!selectedFocus) return; abilitySlots = [...(selectedFocus.abilityIds ?? ['', '', ''])]; @@ -451,6 +461,7 @@ +
@@ -468,6 +479,27 @@
AVAILABILITYIMMEDIATE · NO POINT OR MATERIAL COSTAssigned ability slots 1 and 3 are mirrored into the top-left of the focus tree.
+
+
INTRINSIC ATTACK PROFILEUNMODIFIED DAMAGE BEFORE ENGINEERING, ATTRIBUTES, TALENTS, AND TREE UNLOCKS
{abilityEditing.attack.enabled ? 'DAMAGING ABILITY' : 'NO DIRECT DAMAGE'}
+ setAbilityAttackEnabled(values[0] === 'enabled')}/> + {#if abilityEditing.attack.enabled} +
+ + + + + {#if abilityEditing.attack.delivery === 'periodic'} + + + {/if} + + + + {#if abilityEditing.attack.chargePolicy !== 'none'}{/if} +
+

All damaging skills can critically hit and Multi-Shot. Skill attacks do not headshot. Periodic damage reads active modifiers again on every tick.

+ {/if} +
STRUCTURED COMBAT FACTSATTRIBUTE REFERENCES OR CUSTOM DISPLAY VALUES
{abilityEditing.effects.length} EFFECTS
{#if form?.abilityError}

{form.abilityError}

{/if} @@ -493,7 +525,7 @@
-

Use {`{triggerCount}`}, {`{triggerWindow}`}, {`{duration}`}, {`{maxStacks}`}, and {`{1}`}, {`{2}`} for ordered Attribute modifiers. Trigger and stack values are configured on each tree rank that grants this effect.

+

Use {`{duration}`}, {`{maxStacks}`}, and {`{1}`}, {`{2}`} for ordered calculation outcomes. Each tree rank owns its event branches, lifecycle, scope, and values.

{#if form?.combatEffectError}

{form.combatEffectError}

{/if}
@@ -538,25 +570,12 @@
EFFECT {combatIndex + 1}{combatEffectById(combatEffect.effectId)?.name ?? 'SELECT SHARED EFFECT'}
SHARED EFFECT
-
- - - - - -
-
COUNTER RESETS AFTER PROCSTACK GAIN REFRESHES TIMEREXPIRY CLEARS ALL STACKS
-
- {#each combatEffect.modifiers as modifier, modifierIndex} -
-
ATTRIBUTE PER STACK
- - - -
- {/each} - -
+
{/each} diff --git a/src/routes/isac/talents/+page.server.ts b/src/routes/isac/talents/+page.server.ts index d6f3f59..28026b4 100644 --- a/src/routes/isac/talents/+page.server.ts +++ b/src/routes/isac/talents/+page.server.ts @@ -8,9 +8,23 @@ import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/p import { auditRecord, recordIsacAudit } from '$lib/server/audit'; import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons'; import { getPendingEntities } from '$lib/server/data-moderation'; +import { + conditionalRuleSnapshot, + loadConditionalRuleDraft, + parseConditionalRulePayload, + saveConditionalRuleInTransaction +} from '$lib/server/conditional-effects'; +import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes'; type SharedEffect = { statId: string; valueI: number; valueII: number }; +const descriptionOutcomes = (draft: ReturnType) => { + const outcomes = draft.clauses.flatMap((clause) => clause.outcomes) + .filter((outcome) => outcome.descriptionSlot) + .sort((left, right) => Number(left.descriptionSlot) - Number(right.descriptionSlot)); + return [...new Map(outcomes.map((outcome) => [outcome.descriptionSlot, outcome])).values()]; +}; + export const load: PageServerLoad = async ({ cookies, url }) => { const user = await requireDataView(await getSessionUser(cookies)); const db = await getDatabase(); @@ -35,11 +49,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const args = search ? [pattern] : []; const filteredRow = await (await db.prepare(`SELECT COUNT(*) AS total FROM talents ${where}`)).get(...args); const pagination = getPagination(url, Number(filteredRow?.total ?? 0)); - const [talents, tiers, triggers, links, effects, stats, templates, weaponScopes, weaponSlots, exoticOwners, icons] = await Promise.all([ + const [talents, tiers, effects, stats, templates, weaponScopes, weaponSlots, exoticOwners, icons] = await Promise.all([ (await db.prepare(`SELECT * FROM talents ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset), (await db.prepare('SELECT id,talent_id,tier,description FROM talent_tiers')).all(), - (await db.prepare('SELECT * FROM talent_triggers ORDER BY active DESC,name COLLATE NOCASE')).all(), - (await db.prepare('SELECT talent_tier_id,trigger_id FROM talent_tier_triggers')).all(), (await db.prepare('SELECT talent_tier_id,stat_attribute_id,value FROM talent_tier_effects')).all(), (await db.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), (await db.prepare('SELECT talent_id,template FROM talent_description_templates')).all(), @@ -48,8 +60,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { (await db.prepare('SELECT id,name,exotic_talent_id FROM weapons WHERE exotic_talent_id IS NOT NULL')).all(), (await db.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all() ]); - const [triggerSummary, slotSummary, exoticSummary, gearSummary, pending] = await Promise.all([ - (await db.prepare('SELECT COUNT(*) AS total FROM talent_triggers')).get(), + const [slotSummary, exoticSummary, gearSummary, pending] = await Promise.all([ (await db.prepare(`SELECT SUM(CASE WHEN slot=1 THEN 1 ELSE 0 END) AS slot1, SUM(CASE WHEN slot=2 THEN 1 ELSE 0 END) AS slot2 FROM weapon_talent_slots`)).get(), @@ -59,12 +70,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => { SUM(CASE WHEN category IN ('Vest','Body Armor') THEN 1 ELSE 0 END) AS body_armor FROM talents`)).get(), getPendingEntities(user.id, [ - { module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] }, { module: 'attributes', action: 'save', kind: 'stat', fields: ['unit'] }, - { module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category', 'descriptionTemplate', 'cooldownSeconds', 'durationSeconds', 'maxStacks'] } + { module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category', 'descriptionTemplate', 'conditionalRule'] } ]) ]); - const pendingTriggers = pending.filter((item) => item.kind === 'trigger'); const pendingStats = pending.filter((item) => item.kind === 'stat'); const pendingTalents = pending.filter((item) => item.kind === 'talent').filter((item) => filter === 'slot1' ? item.category === 'Weapon Talent 1' @@ -81,12 +90,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => { duration_seconds: item.durationSeconds || null, max_stacks: item.maxStacks || null, descriptionTemplate: item.descriptionTemplate, + conditionalRule: item.conditionalRule ?? null, ownerWeaponId: null, ownerWeaponName: null, tiers: ['I', 'II'].map((tier) => ({ tier, description: String(item.descriptionTemplate || 'PENDING TALENT CONFIGURATION') })) })); - return { - talents: [...pendingTalents, ...talents.map((talent) => ({ + const configuredTalents = await Promise.all(talents.map(async (talent) => ({ ...talent, iconUrl: (() => { const icon = icons.find((item) => item.entity_id === talent.id); return icon ? gameDataIconUrl('talent', String(talent.id), icon.updated_at) : ''; })(), category: talent.category === 'Vest' @@ -101,19 +110,19 @@ export const load: PageServerLoad = async ({ cookies, url }) => { ? 'Weapon · Slot Unassigned' : talent.category, descriptionTemplate: templates.find((item) => item.talent_id === talent.id)?.template ?? '', + conditionalRule: await loadConditionalRuleDraft(db, 'talent', String(talent.id), ['I', 'II']), ownerWeaponId: exoticOwners.find((weapon) => weapon.exotic_talent_id === talent.id)?.id ?? null, ownerWeaponName: exoticOwners.find((weapon) => weapon.exotic_talent_id === talent.id)?.name ?? null, tiers: tiers.filter((tier) => tier.talent_id === talent.id).map((tier) => ({ ...tier, - triggerIds: links.filter((link) => link.talent_tier_id === tier.id).map((link) => String(link.trigger_id)), effects: effects.filter((effect) => effect.talent_tier_id === tier.id) })) - }))], - triggers: [...pendingTriggers, ...triggers], + }))); + return { + talents: [...pendingTalents, ...configuredTalents], stats: [...pendingStats, ...stats], summary: { total: Number(totalRow?.total ?? 0) + pending.filter((item) => item.kind === 'talent').length, - triggers: Number(triggerSummary?.total ?? 0), slot1: Number(slotSummary?.slot1 ?? 0), slot2: Number(slotSummary?.slot2 ?? 0), exotic: Number(exoticSummary?.total ?? 0), @@ -165,22 +174,6 @@ const renderDescription = ( }); export const actions: Actions = { - saveTrigger: async ({ request, cookies }) => { - const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); - const form = await request.formData(); - const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').trim(), description = String(form.get('description') ?? '').trim(), uptime = form.get('supportsUptime') === 'on'; - const min = uptime ? Number(form.get('minUptime')) : null, max = uptime ? Number(form.get('maxUptime')) : null; - if (name.length < 2 || !description) return fail(400, { triggerError: 'A trigger name and description are required.' }); - if (uptime && (!Number.isFinite(min) || !Number.isFinite(max) || Number(min) < 0 || Number(max) > 100 || Number(min) > Number(max))) return fail(400, { triggerError: 'Uptime must be a valid 0–100% range.' }); - const db = await getDatabase(); - if (await (await db.prepare('SELECT id FROM talent_triggers WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) return fail(409, { triggerError: 'That trigger already exists.' }); - const now = new Date().toISOString(), triggerId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(); - const before = id ? await auditRecord('talent_triggers', id) : null; - if (id) await (await db.prepare('UPDATE talent_triggers SET name=?,description=?,supports_uptime=?,min_uptime=?,max_uptime=?,updated_at=? WHERE id=?')).run(name, description, uptime ? 1 : 0, min, max, now, id); - else await (await db.prepare('INSERT INTO talent_triggers(id,name,description,supports_uptime,min_uptime,max_uptime,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)')).run(triggerId, name, description, uptime ? 1 : 0, min, max, now, now); - await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'talent_trigger.updated' : 'talent_trigger.created', entityId: triggerId, before, after: await auditRecord('talent_triggers', triggerId) }); - return { triggerSaved: true, triggerMode: id ? 'edit' : 'add', resultToken: randomUUID() }; - }, saveTalent: async ({ request, cookies }) => { const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const form = await request.formData(); @@ -189,58 +182,114 @@ export const actions: Actions = { catch (error) { if (error instanceof GameDataIconValidationError) return fail(400, { talentError: error.message }); throw error; } const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').trim(), category = String(form.get('category') ?? ''); const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').trim(); - const cooldownSeconds = optionalPositiveNumber(form, 'cooldownSeconds'); - const durationSeconds = optionalPositiveNumber(form, 'durationSeconds'); - const maxStacks = optionalPositiveNumber(form, 'maxStacks'); - const triggerIds = [...new Set(form.getAll('triggerIds').map(String))]; - let sharedEffects: SharedEffect[]; - try { sharedEffects = parseEffects(form.get('effects')); } catch { return fail(400, { talentError: 'Effects could not be read.' }); } + const db = await getDatabase(); + const conditionalRuleRaw = form.get('conditionalRule'); + let conditionalRule; + try { + const activeStats = await (await db.prepare('SELECT id FROM stat_attributes WHERE active=1')).all(); + conditionalRule = parseConditionalRulePayload(conditionalRuleRaw, [ + { key: 'tierI', variantKey: 'I' }, + { key: 'tierII', variantKey: 'II' } + ], { + allowedAttributeIds: new Set(activeStats.map((row) => String(row.id))), + allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)) + }); + } catch (error) { + return fail(400, { talentError: error instanceof Error ? error.message : 'The conditional mechanic is invalid.' }); + } + const cooldownSeconds = conditionalRule.clauses.find((clause) => clause.cooldownSeconds)?.cooldownSeconds ?? null; + const durationSeconds = conditionalRule.pools.find((pool) => pool.expiresAfterSeconds)?.expiresAfterSeconds ?? null; + const maxStacks = conditionalRule.pools.length + ? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1)) + : null; + const sharedEffects: SharedEffect[] = descriptionOutcomes(conditionalRule) + .filter((effect) => effect.kind === 'attribute_modifier' && effect.attributeId) + .map((effect) => ({ + statId: effect.attributeId ?? '', + valueI: effect.variants.I.value, + valueII: effect.variants.II.value + })); if (name.length < 2 || !['Weapon Talent 1', 'Weapon Talent 2', 'Backpack', 'Body Armor'].includes(category)) return fail(400, { talentError: 'Provide a valid talent name and category. Exotic talents are managed from their owning weapon.' }); - if (!descriptionTemplate || !triggerIds.length || !sharedEffects.length) return fail(400, { talentError: 'A description template, trigger, and at least one effect are required.' }); - if ([cooldownSeconds, durationSeconds].some((value) => value !== null && (!Number.isFinite(value) || value <= 0))) return fail(400, { talentError: 'Cooldown and duration must be positive numbers when defined.' }); - if (maxStacks !== null && (!Number.isInteger(maxStacks) || maxStacks <= 0)) return fail(400, { talentError: 'Maximum stacks must be a positive whole number when defined.' }); - if (sharedEffects.some((effect) => !effect.statId || !Number.isFinite(effect.valueI) || !Number.isFinite(effect.valueII))) return fail(400, { talentError: 'Every effect needs valid Tier I and Tier II values.' }); - if (new Set(sharedEffects.map((effect) => effect.statId)).size !== sharedEffects.length) return fail(400, { talentError: 'Each affected attribute can only be selected once.' }); + if (!descriptionTemplate || !conditionalRule.clauses.length || !sharedEffects.length) return fail(400, { talentError: 'A description template, mechanic branch, and at least one numbered Attribute outcome are required.' }); + if (sharedEffects.some((effect) => !Number.isFinite(effect.valueI) || !Number.isFinite(effect.valueII))) return fail(400, { talentError: 'Every outcome needs valid Tier I and Tier II values.' }); const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1])); if (placeholders.some((index) => index < 1 || index > sharedEffects.length)) return fail(400, { talentError: 'Description placeholders must reference an existing effect.' }); if (descriptionTemplate.includes('{duration}') && durationSeconds === null) return fail(400, { talentError: 'The description uses {duration}, but no duration is defined.' }); if (descriptionTemplate.includes('{max_stacks}') && maxStacks === null) return fail(400, { talentError: 'The description uses {max_stacks}, but no maximum stack count is defined.' }); const storedCategory = category === 'Body Armor' ? 'Vest' : category.startsWith('Weapon Talent') ? 'Weapon' : category; - const db = await getDatabase(); if (await (await db.prepare('SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category=? AND id<>?')).get(name, storedCategory, id || 'new')) return fail(409, { talentError: 'That talent already exists in this category.' }); - const statRows = await (await db.prepare(`SELECT id,unit FROM stat_attributes WHERE active=1 AND id IN (${sharedEffects.map(() => '?').join(',')})`)).all(...sharedEffects.map((effect) => effect.statId)); - if (statRows.length !== sharedEffects.length) return fail(400, { talentError: 'One or more selected attributes are unavailable.' }); + const statIds = conditionalRule.clauses.flatMap((clause) => clause.outcomes) + .map((effect) => effect.attributeId) + .filter((value): value is string => Boolean(value)); + const statRows = statIds.length + ? await (await db.prepare(`SELECT id,unit FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).all(...statIds) + : []; + if (statRows.length !== new Set(statIds).size) return fail(400, { talentError: 'One or more selected attributes are unavailable.' }); const units = new Map(statRows.map((stat) => [String(stat.id), stat.unit])); const talentId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(), now = new Date().toISOString(); - const before = id ? { record: await auditRecord('talents', id), icon: await gameDataIconMetadata(db, 'talent', id), template: await (await db.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(id), tiers: await (await db.prepare('SELECT tier,description FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(id) } : null; - if (id) await (await db.prepare('UPDATE talents SET name=?,category=?,cooldown_seconds=?,duration_seconds=?,max_stacks=?,updated_at=? WHERE id=?')).run(name, storedCategory, cooldownSeconds, durationSeconds, maxStacks, now, id); - else await (await db.prepare('INSERT INTO talents(id,name,category,cooldown_seconds,duration_seconds,max_stacks,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)')).run(talentId, name, storedCategory, cooldownSeconds, durationSeconds, maxStacks, now, now); - if (storedCategory === 'Weapon') { - await (await db.prepare(`INSERT INTO talent_weapon_scopes(talent_id,scope) VALUES(?,?) - ON CONFLICT(talent_id) DO UPDATE SET scope=excluded.scope`)) - .run(talentId, 'Standard'); - await (await db.prepare(`INSERT INTO weapon_talent_slots(talent_id,slot) VALUES(?,?) - ON CONFLICT(talent_id) DO UPDATE SET slot=excluded.slot`)) - .run(talentId, category === 'Weapon Talent 1' ? 1 : 2); - } else { - await (await db.prepare('DELETE FROM talent_weapon_scopes WHERE talent_id=?')).run(talentId); - await (await db.prepare('DELETE FROM weapon_talent_slots WHERE talent_id=?')).run(talentId); + const before = id ? { + record: await auditRecord('talents', id), + icon: await gameDataIconMetadata(db, 'talent', id), + template: await (await db.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(id), + tiers: await (await db.prepare('SELECT tier,description FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(id), + conditional: await conditionalRuleSnapshot(db, 'talent', id) + } : null; + await db.exec('BEGIN'); + try { + if (id) await (await db.prepare('UPDATE talents SET name=?,category=?,cooldown_seconds=?,duration_seconds=?,max_stacks=?,updated_at=? WHERE id=?')).run(name, storedCategory, cooldownSeconds, durationSeconds, maxStacks, now, id); + else await (await db.prepare('INSERT INTO talents(id,name,category,cooldown_seconds,duration_seconds,max_stacks,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)')).run(talentId, name, storedCategory, cooldownSeconds, durationSeconds, maxStacks, now, now); + if (storedCategory === 'Weapon') { + await (await db.prepare(`INSERT INTO talent_weapon_scopes(talent_id,scope) VALUES(?,?) + ON CONFLICT(talent_id) DO UPDATE SET scope=excluded.scope`)) + .run(talentId, 'Standard'); + await (await db.prepare(`INSERT INTO weapon_talent_slots(talent_id,slot) VALUES(?,?) + ON CONFLICT(talent_id) DO UPDATE SET slot=excluded.slot`)) + .run(talentId, category === 'Weapon Talent 1' ? 1 : 2); + } else { + await (await db.prepare('DELETE FROM talent_weapon_scopes WHERE talent_id=?')).run(talentId); + await (await db.prepare('DELETE FROM weapon_talent_slots WHERE talent_id=?')).run(talentId); + } + await (await db.prepare(`INSERT INTO talent_description_templates(talent_id,template,updated_at) VALUES(?,?,?) + ON CONFLICT(talent_id) DO UPDATE SET template=excluded.template,updated_at=excluded.updated_at`)).run(talentId, descriptionTemplate, now); + for (const tier of ['I', 'II'] as const) { + const description = renderDescription(descriptionTemplate, sharedEffects, units, tier, durationSeconds, maxStacks); + await (await db.prepare(`INSERT INTO talent_tiers(id,talent_id,tier,description,trigger_text,effect_text,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?) + ON CONFLICT(talent_id,tier) DO UPDATE SET description=excluded.description,updated_at=excluded.updated_at`)).run(randomUUID(), talentId, tier, description, '', '', now, now); + const tierRow = await (await db.prepare('SELECT id FROM talent_tiers WHERE talent_id=? AND tier=?')).get(talentId, tier); + const tierId = String(tierRow?.id); + await (await db.prepare('DELETE FROM talent_tier_effects WHERE talent_tier_id=?')).run(tierId); + for (const outcome of descriptionOutcomes(conditionalRule)) { + if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId) continue; + await (await db.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)')).run( + randomUUID(), + tierId, + outcome.attributeId, + tier === 'I' ? outcome.variants.I.value : outcome.variants.II.value, + now + ); + } + } + await saveConditionalRuleInTransaction(db, 'talent', talentId, conditionalRule); + await applyIconMutation(db, 'talent', talentId, iconMutation, operator.id); + await recordIsacAudit({ + actorId: operator.id, + category: 'game_data', + action: id ? 'talent.updated' : 'talent.created', + entityId: talentId, + before, + after: { + record: await auditRecord('talents', talentId), + icon: await gameDataIconMetadata(db, 'talent', talentId), + category, + descriptionTemplate, + conditional: await conditionalRuleSnapshot(db, 'talent', talentId) + } + }); + await db.exec('COMMIT'); + } catch (error) { + await db.exec('ROLLBACK'); + throw error; } - await (await db.prepare(`INSERT INTO talent_description_templates(talent_id,template,updated_at) VALUES(?,?,?) - ON CONFLICT(talent_id) DO UPDATE SET template=excluded.template,updated_at=excluded.updated_at`)).run(talentId, descriptionTemplate, now); - for (const tier of ['I', 'II'] as const) { - const description = renderDescription(descriptionTemplate, sharedEffects, units, tier, durationSeconds, maxStacks); - await (await db.prepare(`INSERT INTO talent_tiers(id,talent_id,tier,description,trigger_text,effect_text,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?) - ON CONFLICT(talent_id,tier) DO UPDATE SET description=excluded.description,updated_at=excluded.updated_at`)).run(randomUUID(), talentId, tier, description, '', '', now, now); - const tierRow = await (await db.prepare('SELECT id FROM talent_tiers WHERE talent_id=? AND tier=?')).get(talentId, tier); - const tierId = String(tierRow?.id); - await (await db.prepare('DELETE FROM talent_tier_triggers WHERE talent_tier_id=?')).run(tierId); - await (await db.prepare('DELETE FROM talent_tier_effects WHERE talent_tier_id=?')).run(tierId); - for (const triggerId of triggerIds) await (await db.prepare('INSERT INTO talent_tier_triggers(talent_tier_id,trigger_id) VALUES(?,?)')).run(tierId, triggerId); - for (const effect of sharedEffects) await (await db.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)')).run(randomUUID(), tierId, effect.statId, tier === 'I' ? effect.valueI : effect.valueII, now); - } - await applyIconMutation(db, 'talent', talentId, iconMutation, operator.id); - await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'talent.updated' : 'talent.created', entityId: talentId, before, after: { record: await auditRecord('talents', talentId), icon: await gameDataIconMetadata(db, 'talent', talentId), category, descriptionTemplate, triggerIds, effects: sharedEffects } }); return { talentSaved: true, talentMode: id ? 'edit' : 'add', resultToken: randomUUID() }; } }; diff --git a/src/routes/isac/talents/+page.svelte b/src/routes/isac/talents/+page.svelte index 9179641..c54149f 100644 --- a/src/routes/isac/talents/+page.svelte +++ b/src/routes/isac/talents/+page.svelte @@ -6,57 +6,73 @@ import ItemIcon from '$lib/components/ItemIcon.svelte'; import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte'; import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte'; - import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; + import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte'; + import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor'; let { data, form } = $props(); - type Effect = { statId: string; valueI: string; valueII: string }; const categoryOptions = ['Weapon Talent 1', 'Weapon Talent 2', 'Backpack', 'Body Armor'].map((value) => ({ value, label: value })); - let talent = $state(null), trigger = $state(null), uptime = $state(false); + let talent = $state(null); let handledResultToken = $state(''); let iconResetToken = $state(0); - const blankTalent = () => ({ id: '', name: '', iconUrl: '', category: 'Weapon Talent 1', cooldown_seconds: '', duration_seconds: '', max_stacks: '', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] }); + const blankRule = (): ConditionalMechanicDraftV1 => ({ + version: 1, + simulationReadiness: 'captured_only', + scenarioInputs: [], + pools: [], + clauses: [{ + key: 'primary', + label: 'Primary', + event: 'weapon_hit', + if: { kind: 'true' }, + reducer: { kind: 'none' }, + mutations: [], + outcomes: [{ + key: 'effect-1', + label: 'Effect 1', + phase: 'while_active', + kind: 'attribute_modifier', + scope: 'equipped_weapon', + attributeId: '', + basis: 'flat', + perStack: true, + descriptionSlot: 1, + variants: { I: { value: 0 }, II: { value: 0 } } + }] + }] + }); + const blankTalent = () => ({ id: '', name: '', iconUrl: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', conditionalRule: blankRule() }); const editTalent = (row: any) => { - const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', triggerIds: [], effects: [] }; + const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', effects: [] }; const one = tier('I'), two = tier('II'); - const statIds = [...new Set([...one.effects, ...two.effects].map((effect: any) => String(effect.stat_attribute_id)))]; talent = { ...row, - cooldown_seconds: row.cooldown_seconds ?? '', - duration_seconds: row.duration_seconds ?? '', - max_stacks: row.max_stacks ?? '', descriptionTemplate: row.descriptionTemplate || one.description, tierIDescription: one.description, tierIIDescription: two.description, - triggerIds: [...new Set([...one.triggerIds, ...two.triggerIds])], - effects: statIds.map((statId) => ({ - statId, - valueI: String(one.effects.find((effect: any) => String(effect.stat_attribute_id) === statId)?.value ?? ''), - valueII: String(two.effects.find((effect: any) => String(effect.stat_attribute_id) === statId)?.value ?? '') - })) + conditionalRule: row.conditionalRule ?? blankRule() }; }; - const blankTrigger = () => { uptime = false; trigger = { id: '', name: '', description: '', min_uptime: '', max_uptime: '' }; }; - const editTrigger = (row: any) => { uptime = Number(row.supports_uptime) === 1; trigger = { ...row }; }; - const toggleTrigger = (id: string, checked: boolean) => talent.triggerIds = checked ? [...talent.triggerIds, id] : talent.triggerIds.filter((value: string) => value !== id); - const addEffect = () => talent.effects = [...talent.effects, { statId: '', valueI: '', valueII: '' }]; - const removeEffect = (index: number) => talent.effects = talent.effects.filter((_: Effect, row: number) => row !== index); const configured = (tiers: Array<{ tier: unknown }>, name: string) => tiers.some((item) => item.tier === name); const tierDescription = (tiers: Array<{ tier: unknown; description?: unknown }>, name: string) => { const description = tiers.find((item) => item.tier === name)?.description; return typeof description === 'string' && description.trim() ? description : 'NOT CONFIGURED'; }; - const displayValue = (effect: Effect, tier: 'I' | 'II') => { - const stat = data.stats.find((item) => item.id === effect.statId); - const value = tier === 'I' ? effect.valueI : effect.valueII; + const displayValue = (effect: any, tier: 'I' | 'II') => { + const stat = data.stats.find((item) => item.id === effect.attributeId); + const value = effect.variants?.[tier]?.value ?? ''; const unit = stat?.unit === 'flat' ? '' : stat?.unit ?? ''; return unit === 'Shots' ? `${value} ${unit}` : `${value}${unit}`; }; const mechanicValue = (value: string | number, singular: string) => `${value} ${Number(value) === 1 ? singular : `${singular}s`}`; const cooldownLine = (value: unknown) => value === null || value === undefined || value === '' ? '' : `Talent Cooldown: ${mechanicValue(value as string | number, 'second')}`; const preview = (tier: 'I' | 'II') => talent.descriptionTemplate.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => { - if (expression === 'duration') return talent.duration_seconds === '' ? placeholder : mechanicValue(talent.duration_seconds, 'second'); - if (expression === 'max_stacks') return talent.max_stacks === '' ? placeholder : mechanicValue(talent.max_stacks, 'stack'); - const effect = talent.effects[Number(expression) - 1]; + const duration = talent.conditionalRule.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds; + const maxStacks = talent.conditionalRule.pools.length ? Math.max(...talent.conditionalRule.pools.map((pool: any) => pool.max ?? 1)) : null; + if (expression === 'duration') return duration == null ? placeholder : mechanicValue(duration, 'second'); + if (expression === 'max_stacks') return maxStacks == null ? placeholder : mechanicValue(maxStacks, 'stack'); + const effect = talent.conditionalRule.clauses + .flatMap((clause: any) => clause.outcomes) + .find((outcome: any) => outcome.descriptionSlot === Number(expression)); return effect ? displayValue(effect, tier) : placeholder; }); $effect(() => { @@ -66,35 +82,28 @@ if (form.talentMode === 'add') iconResetToken += 1; talent = form.talentMode === 'add' ? blankTalent() : null; } - if (form.triggerSaved) { - if (form.triggerMode === 'add') blankTrigger(); - else trigger = null; - } }); Talents — SHD.build admin -

TALENT REGISTRY

TALENTS

Shared behavior with tier-specific values, reusable triggers, and attribute effects.

+

TALENT REGISTRY

TALENTS

Tier-specific Talent values and source-owned combat mechanics.

MECHANIC REFERENCE →
-{#if trigger} -

{trigger.id ? 'MODIFY TRIGGER' : 'NEW TRIGGER'}

{trigger.id ? trigger.name : 'TRIGGER DEFINITION'}

CALCULATION SCRIPTReserved for the future calculation engine

{#if form?.triggerError}

{form.triggerError}

{/if}
-{/if} - {#snippet talentEditor()} {#if talent}

{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}

{talent.id ? talent.name : 'TALENT DEFINITION'}

- +
-
TIER I PREVIEW

{preview('I') || 'Description preview'}

TIER II PREVIEW

{preview('II') || 'Description preview'}

-

SHARED ACTIVATION TRIGGERS

-
{#each data.triggers.filter((item) => Number(item.active) === 1) as item}
{#if item.pending}PENDING · {item.proposerName}{:else}{/if}
{/each}
-

SHARED ATTRIBUTE EFFECTS · TIER VALUES

-
ATTRIBUTETIER I VALUETIER II VALUE
{#each talent.effects as effect, index}
{/each}
+ {#if form?.talentError}

{form.talentError}

{/if}
@@ -107,4 +116,4 @@ {/if} -
{#each data.talents as item}
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}{#if cooldownLine(item.cooldown_seconds)}{cooldownLine(item.cooldown_seconds)}{/if}
{item.category}

{tierDescription(item.tiers, 'I')}

{tierDescription(item.tiers, 'II')}

{item.pending ? 'PENDING' : 'ACTIVE'}{#if item.pending}{:else if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}EDIT WEAPON{:else}{/if}{:else}{/if}
{#if String(talent?.id ?? '') === String(item.id)}{@render talentEditor()}{/if}{/each}
+
{#each data.talents as item}
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}{#if cooldownLine(item.cooldown_seconds)}{cooldownLine(item.cooldown_seconds)}{/if}
{item.category}

{tierDescription(item.tiers, 'I')}

{tierDescription(item.tiers, 'II')}

{item.pending ? 'PENDING' : item.conditionalRule?.clauses?.length ? 'MODELED' : 'CONFIG REQUIRED'}{#if item.pending}{:else if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}EDIT WEAPON{:else}{/if}{:else}{/if}
{#if String(talent?.id ?? '') === String(item.id)}{@render talentEditor()}{/if}{/each}
diff --git a/src/routes/isac/weapons/+page.server.ts b/src/routes/isac/weapons/+page.server.ts index c27046e..e9cdab8 100644 --- a/src/routes/isac/weapons/+page.server.ts +++ b/src/routes/isac/weapons/+page.server.ts @@ -8,12 +8,19 @@ import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/p import { auditRecord, recordIsacAudit } from '$lib/server/audit'; import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons'; import { getPendingEntities } from '$lib/server/data-moderation'; +import { + conditionalRuleSnapshot, + loadConditionalRuleDraft, + parseConditionalRulePayload, + saveConditionalRuleInTransaction +} from '$lib/server/conditional-effects'; +import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes'; const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG']; const damageTypes = ['Blast', 'Pierce', 'Shredding']; const rangeNames = ['damage'] as const; const modSlotTypes = ['Optics', 'Muzzle', 'Underbarrel', 'Magazine', 'Barrel', 'Upper Receiver', 'Lower Receiver', 'Trigger'] as const; -type ExoticEffect = { statId: string; valueI: number; valueII: number }; +type ExoticEffect = { statId: string | null; valueI: number; valueII: number }; const mechanicValue = (value: number, singular: string) => `${value} ${value === 1 ? singular : `${singular}s`}`; const renderTalentDescription = (template: string, effects: ExoticEffect[], units: Map, tier: 'I' | 'II', duration: number | null, maxStacks: number | null) => @@ -23,10 +30,17 @@ const renderTalentDescription = (template: string, effects: ExoticEffect[], unit const effect = effects[Number(expression) - 1]; if (!effect) return placeholder; const value = tier === 'I' ? effect.valueI : effect.valueII; - const unit = units.get(effect.statId) === 'flat' ? '' : String(units.get(effect.statId) ?? ''); + const unitValue = effect.statId ? units.get(effect.statId) : ''; + const unit = unitValue === 'flat' ? '' : String(unitValue ?? ''); return unit === 'Shots' ? `${value} ${unit}` : `${value}${unit}`; }); +const mechanicOutcomes = (rule: ReturnType) => + rule.clauses.flatMap((clause) => clause.outcomes); +const descriptionOutcomes = (rule: ReturnType) => + mechanicOutcomes(rule).filter((outcome) => outcome.descriptionSlot) + .sort((left, right) => Number(left.descriptionSlot) - Number(right.descriptionSlot)); + export const load: PageServerLoad = async ({ cookies, url }) => { const user = await requireDataView(await getSessionUser(cookies)); const database = await getDatabase(); @@ -62,7 +76,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const whereArgs = [...(requestedWeaponId ? [requestedWeaponId] : []), ...(!requestedWeaponId && search ? [pattern] : [])]; const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM weapons ${where}`)).get(...whereArgs); const pagination = getPagination(url, Number(filtered?.total ?? 0)); - const [weapons, tierRanges, modSlots, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, stats, triggers, exoticTemplates, exoticTiers, exoticTierTriggers, exoticTierEffects, talentIcons, pending] = await Promise.all([ + const [weapons, tierRanges, modSlots, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, stats, exoticTemplates, talentIcons, pending] = await Promise.all([ (await database.prepare(`SELECT * FROM weapons ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...whereArgs, pagination.perPage, pagination.offset), (await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(), (await database.prepare('SELECT * FROM weapon_mod_slots ORDER BY tier,slot_type')).all(), @@ -80,20 +94,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => { WHERE talents.active=1 AND talents.category='Weapon' AND talent_weapon_scopes.scope='Standard' AND weapon_talent_slots.slot=2 ORDER BY talents.name COLLATE NOCASE`)).all(), (await database.prepare("SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE")).all(), - (await database.prepare("SELECT id,name,description FROM talent_triggers WHERE active=1 ORDER BY name COLLATE NOCASE")).all(), (await database.prepare(`SELECT talent_description_templates.talent_id,talent_description_templates.template,talents.name, talents.cooldown_seconds,talents.duration_seconds,talents.max_stacks FROM talent_description_templates JOIN talents ON talents.id=talent_description_templates.talent_id`)).all(), - (await database.prepare(`SELECT talent_tiers.id,talent_tiers.talent_id,talent_tiers.tier,talent_tiers.description - FROM talent_tiers JOIN talents ON talents.id=talent_tiers.talent_id WHERE talents.category='Weapon'`)).all(), - (await database.prepare('SELECT talent_tier_id,trigger_id FROM talent_tier_triggers')).all(), - (await database.prepare('SELECT talent_tier_id,stat_attribute_id,value FROM talent_tier_effects')).all(), (await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all(), getPendingEntities(user.id, [ { module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] }, - { module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] }, { module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] }, - { module: 'weapons', action: 'save', kind: 'weapon', fields: ['category', 'nativeTier', 'damageType', 'isExotic', 'rpm', 'magazine', 'reloadSpeed', 'accuracy', 'stability', 'talent1Ids', 'talent2Ids', 'modSlots'] } + { module: 'weapons', action: 'save', kind: 'weapon', fields: ['category', 'nativeTier', 'damageType', 'isExotic', 'rpm', 'magazine', 'reloadSpeed', 'accuracy', 'stability', 'talent1Ids', 'talent2Ids', 'modSlots', 'exoticTalentName', 'exoticDescriptionTemplate', 'exoticConditionalRule'] } ]) ]); const pendingTalents = pending.filter((item) => item.kind === 'talent'); @@ -130,16 +138,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => { : String(leftValue).localeCompare(String(rightValue), undefined, { sensitivity: 'base', numeric: true }); return (sort.direction === 'asc' ? result : -result) || String(left.name).localeCompare(String(right.name), undefined, { sensitivity: 'base' }); }); - return { - weapons: [...pendingWeapons, ...weapons.map((weapon) => { + const configuredWeapons = await Promise.all(weapons.map(async (weapon) => { const talentId = typeof weapon.exotic_talent_id === 'string' ? weapon.exotic_talent_id : ''; - const talentRows = exoticTiers.filter((tier) => tier.talent_id === talentId); - const tier = (name: string) => talentRows.find((item) => item.tier === name); - const tierI = tier('I'), tierII = tier('II'); - const effectIds = [...new Set([ - ...exoticTierEffects.filter((effect) => effect.talent_tier_id === tierI?.id), - ...exoticTierEffects.filter((effect) => effect.talent_tier_id === tierII?.id) - ].map((effect) => String(effect.stat_attribute_id)))]; + const conditionalRule = talentId + ? await loadConditionalRuleDraft(database, 'talent', talentId, ['I', 'II']) + : null; return { ...weapon, tiers: tierRanges.filter((range) => range.weapon_id === weapon.id), @@ -156,21 +159,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => { duration_seconds: exoticTemplates.find((item) => item.talent_id === talentId)?.duration_seconds ?? '', max_stacks: exoticTemplates.find((item) => item.talent_id === talentId)?.max_stacks ?? '', descriptionTemplate: String(exoticTemplates.find((item) => item.talent_id === talentId)?.template ?? ''), - triggerIds: [...new Set([ - ...exoticTierTriggers.filter((link) => link.talent_tier_id === tierI?.id), - ...exoticTierTriggers.filter((link) => link.talent_tier_id === tierII?.id) - ].map((link) => String(link.trigger_id)))], - effects: effectIds.map((statId) => ({ - statId, - valueI: exoticTierEffects.find((effect) => effect.talent_tier_id === tierI?.id && String(effect.stat_attribute_id) === statId)?.value ?? '', - valueII: exoticTierEffects.find((effect) => effect.talent_tier_id === tierII?.id && String(effect.stat_attribute_id) === statId)?.value ?? '' - })) + conditionalRule } : null - }})], + }})); + return { + weapons: [...pendingWeapons, ...configuredWeapons], talent1Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 1'), ...talent1Options], talent2Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 2'), ...talent2Options], stats: [...pending.filter((item) => item.kind === 'attribute'), ...stats], - triggers: [...pending.filter((item) => item.kind === 'trigger'), ...triggers], summary: { ...summary, total: Number(summary?.total ?? 0) + pending.filter((item) => item.kind === 'weapon').length, pending: pending.filter((item) => item.kind === 'weapon').length }, pagination, filter, @@ -185,11 +181,6 @@ function number(form: FormData, name: string): number { return Number(form.get(name)); } -function optionalPositiveNumber(form: FormData, name: string): number | null { - const raw = String(form.get(name) ?? '').trim(); - return raw ? Number(raw) : null; -} - function readRange(form: FormData, tier: number, range: (typeof rangeNames)[number]) { const min = number(form, `tier${tier}${range}Min`); const max = number(form, `tier${tier}${range}Max`); @@ -228,18 +219,35 @@ export const actions: Actions = { const existingExoticTalentId = isExotic ? String(form.get('exoticTalentId') ?? '') : ''; const exoticTalentName = isExotic ? String(form.get('exoticTalentName') ?? '').normalize('NFKC').trim() : ''; const exoticDescriptionTemplate = isExotic ? String(form.get('exoticDescriptionTemplate') ?? '').trim() : ''; - const exoticCooldownSeconds = isExotic ? optionalPositiveNumber(form, 'exoticCooldownSeconds') : null; - const exoticDurationSeconds = isExotic ? optionalPositiveNumber(form, 'exoticDurationSeconds') : null; - const exoticMaxStacks = isExotic ? optionalPositiveNumber(form, 'exoticMaxStacks') : null; - const exoticTriggerIds = isExotic ? [...new Set(form.getAll('exoticTriggerIds').map(String))] : []; - let exoticEffects: ExoticEffect[] = []; - try { - const parsed = JSON.parse(String(form.get('exoticEffects') ?? '[]')) as Array>; - if (!Array.isArray(parsed)) throw new Error(); - exoticEffects = parsed.map((effect) => ({ statId: String(effect.statId ?? ''), valueI: Number(effect.valueI), valueII: Number(effect.valueII) })); - } catch { - return fail(400, { weaponError: 'The exotic talent effects could not be read.' }); + const database = await getDatabase(); + let exoticConditionalRule = null; + if (isExotic) { + const rawRule = form.get('exoticConditionalRule'); + try { + const activeStats = await (await database.prepare('SELECT id FROM stat_attributes WHERE active=1')).all(); + exoticConditionalRule = parseConditionalRulePayload(rawRule, [ + { key: 'tierI', variantKey: 'I' }, + { key: 'tierII', variantKey: 'II' } + ], { + allowedAttributeIds: new Set(activeStats.map((row) => String(row.id))), + allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)) + }); + } catch (error) { + return fail(400, { weaponError: error instanceof Error ? error.message : 'The exotic talent mechanic is invalid.' }); + } } + const exoticCooldownSeconds = exoticConditionalRule?.clauses.find((clause) => clause.cooldownSeconds)?.cooldownSeconds ?? null; + const exoticDurationSeconds = exoticConditionalRule?.pools.find((pool) => pool.expiresAfterSeconds)?.expiresAfterSeconds ?? null; + const exoticMaxStacks = exoticConditionalRule?.pools.length + ? Math.max(...exoticConditionalRule.pools.map((pool) => pool.max ?? 1)) + : null; + const exoticEffects: ExoticEffect[] = exoticConditionalRule ? descriptionOutcomes(exoticConditionalRule) + .filter((outcome) => outcome.kind === 'attribute_modifier') + .map((outcome) => ({ + statId: outcome.attributeId ?? null, + valueI: outcome.variants.I.value, + valueII: outcome.variants.II.value + })) : []; if (name.length < 2 || name.length > 80) return fail(400, { weaponError: 'Weapon names must be 2–80 characters.' }); if (!categories.includes(category) || !damageTypes.includes(damageType) || !Number.isInteger(nativeTier) || nativeTier < 0 || nativeTier > 2) { @@ -262,18 +270,8 @@ export const actions: Actions = { return fail(400, { weaponError: `Tier ${tier} requires exactly ${required} unique Weapon Mod slots.` }); } } - if (isExotic && (exoticTalentName.length < 2 || !exoticDescriptionTemplate || !exoticTriggerIds.length || !exoticEffects.length)) { - return fail(400, { weaponError: 'Exotic weapons require a named talent, description template, trigger, and at least one effect.' }); - } - if (isExotic && [exoticCooldownSeconds, exoticDurationSeconds].some((value) => value !== null && (!Number.isFinite(value) || value <= 0))) { - return fail(400, { weaponError: 'Exotic talent cooldown and duration must be positive numbers when defined.' }); - } - if (isExotic && exoticMaxStacks !== null && (!Number.isInteger(exoticMaxStacks) || exoticMaxStacks <= 0)) { - return fail(400, { weaponError: 'Exotic talent maximum stacks must be a positive whole number when defined.' }); - } - if (isExotic && (exoticEffects.some((effect) => !effect.statId || !Number.isFinite(effect.valueI) || !Number.isFinite(effect.valueII)) - || new Set(exoticEffects.map((effect) => effect.statId)).size !== exoticEffects.length)) { - return fail(400, { weaponError: 'Every exotic talent effect requires one unique attribute and valid Tier I and Tier II values.' }); + if (isExotic && (exoticTalentName.length < 2 || !exoticDescriptionTemplate || !exoticConditionalRule)) { + return fail(400, { weaponError: 'Exotic weapons require a named talent, description template, and modeled conditional mechanic.' }); } if (isExotic) { const placeholders = [...exoticDescriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1])); @@ -321,7 +319,6 @@ export const actions: Actions = { return fail(400, { weaponError: 'Each base attribute bonus must reference a different attribute.' }); } - const database = await getDatabase(); if (await (await database.prepare('SELECT id FROM weapons WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) { return fail(409, { weaponError: 'That weapon name already exists.' }); } @@ -333,12 +330,14 @@ export const actions: Actions = { AND weapon_talent_slots.slot=? AND talents.id IN (${ids.map(() => '?').join(',')})`)).get(slot, ...ids); if (Number(validTalents?.count) !== ids.length) return fail(400, { weaponError: `One or more selected Talent ${slot} options are unavailable or assigned to the wrong slot.` }); } - const statIds = [...new Set([...augmentEffects.map((effect) => effect.statId), ...baseBonuses.map((effect) => effect.statId), ...exoticEffects.map((effect) => effect.statId)])]; + const statIds = [...new Set([ + ...augmentEffects.map((effect) => effect.statId), + ...baseBonuses.map((effect) => effect.statId), + ...exoticEffects.map((effect) => effect.statId).filter((value): value is string => Boolean(value)) + ])]; const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).get(...statIds); if (Number(validStats?.count) !== statIds.length) return fail(400, { weaponError: 'One or more tactical augmentation attributes are unavailable.' }); if (isExotic) { - const validTriggers = await (await database.prepare(`SELECT COUNT(*) AS count FROM talent_triggers WHERE active=1 AND id IN (${exoticTriggerIds.map(() => '?').join(',')})`)).get(...exoticTriggerIds); - if (Number(validTriggers?.count) !== exoticTriggerIds.length) return fail(400, { weaponError: 'One or more exotic talent triggers are unavailable.' }); if (existingExoticTalentId) { const owned = id ? await (await database.prepare('SELECT id FROM weapons WHERE id=? AND exotic_talent_id=?')).get(id, existingExoticTalentId) : null; if (!owned) return fail(400, { weaponError: 'The submitted exotic talent is not owned by this weapon.' }); @@ -349,6 +348,17 @@ export const actions: Actions = { return fail(409, { weaponError: 'An exotic talent with that name already exists.' }); } } + if (id && !await (await database.prepare('SELECT id FROM weapons WHERE id=?')).get(id)) { + return fail(404, { weaponError: 'Weapon not found.' }); + } + const sharedCoreRanges = await (await database.prepare( + 'SELECT * FROM core_attribute_tier_ranges ORDER BY tier' + )).all(); + for (const tier of tiers) { + if (!sharedCoreRanges.some((range) => Number(range.tier) === tier.tier)) { + return fail(500, { weaponError: `Global core configuration is missing T${tier.tier}.` }); + } + } const weaponId = id || randomUUID(); const now = new Date().toISOString(); @@ -365,10 +375,11 @@ export const actions: Actions = { icon: await gameDataIconMetadata(database, 'talent', existingExoticTalentId), template: await (await database.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(existingExoticTalentId), tiers: await (await database.prepare('SELECT * FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(existingExoticTalentId), - triggers: await (await database.prepare(`SELECT talent_tier_triggers.* FROM talent_tier_triggers JOIN talent_tiers ON talent_tiers.id=talent_tier_triggers.talent_tier_id WHERE talent_tiers.talent_id=?`)).all(existingExoticTalentId), - effects: await (await database.prepare(`SELECT talent_tier_effects.* FROM talent_tier_effects JOIN talent_tiers ON talent_tiers.id=talent_tier_effects.talent_tier_id WHERE talent_tiers.talent_id=?`)).all(existingExoticTalentId) + conditional: await conditionalRuleSnapshot(database, 'talent', existingExoticTalentId) } : null } : null; + await database.exec('BEGIN'); + try { if (isExotic && exoticTalentId) { if (existingExoticTalentId) { await (await database.prepare("UPDATE talents SET name=?,category='Weapon',cooldown_seconds=?,duration_seconds=?,max_stacks=?,active=1,updated_at=? WHERE id=?")).run(exoticTalentName, exoticCooldownSeconds, exoticDurationSeconds, exoticMaxStacks, now, exoticTalentId); @@ -380,7 +391,10 @@ export const actions: Actions = { await (await database.prepare('DELETE FROM weapon_talent_slots WHERE talent_id=?')).run(exoticTalentId); await (await database.prepare(`INSERT INTO talent_description_templates(talent_id,template,updated_at) VALUES(?,?,?) ON CONFLICT(talent_id) DO UPDATE SET template=excluded.template,updated_at=excluded.updated_at`)).run(exoticTalentId, exoticDescriptionTemplate, now); - const unitRows = await (await database.prepare(`SELECT id,unit FROM stat_attributes WHERE id IN (${exoticEffects.map(() => '?').join(',')})`)).all(...exoticEffects.map((effect) => effect.statId)); + const effectStatIds = exoticEffects.map((effect) => effect.statId).filter((value): value is string => Boolean(value)); + const unitRows = effectStatIds.length + ? await (await database.prepare(`SELECT id,unit FROM stat_attributes WHERE id IN (${effectStatIds.map(() => '?').join(',')})`)).all(...effectStatIds) + : []; const units = new Map(unitRows.map((row) => [String(row.id), row.unit])); for (const talentTier of ['I', 'II'] as const) { const description = renderTalentDescription(exoticDescriptionTemplate, exoticEffects, units, talentTier, exoticDurationSeconds, exoticMaxStacks); @@ -389,17 +403,16 @@ export const actions: Actions = { .run(randomUUID(), exoticTalentId, talentTier, description, '', '', now, now); const tierRow = await (await database.prepare('SELECT id FROM talent_tiers WHERE talent_id=? AND tier=?')).get(exoticTalentId, talentTier); const tierId = String(tierRow?.id); - await (await database.prepare('DELETE FROM talent_tier_triggers WHERE talent_tier_id=?')).run(tierId); await (await database.prepare('DELETE FROM talent_tier_effects WHERE talent_tier_id=?')).run(tierId); - for (const triggerId of exoticTriggerIds) await (await database.prepare('INSERT INTO talent_tier_triggers(talent_tier_id,trigger_id) VALUES(?,?)')).run(tierId, triggerId); - for (const effect of exoticEffects) await (await database.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)')) - .run(randomUUID(), tierId, effect.statId, talentTier === 'I' ? effect.valueI : effect.valueII, now); + for (const effect of exoticEffects.filter((outcome) => outcome.statId)) { + await (await database.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)')) + .run(randomUUID(), tierId, effect.statId, talentTier === 'I' ? effect.valueI : effect.valueII, now); + } } + await saveConditionalRuleInTransaction(database, 'talent', exoticTalentId, exoticConditionalRule!); await applyIconMutation(database, 'talent', exoticTalentId, exoticIconMutation, operator.id); } if (id) { - const exists = await (await database.prepare('SELECT id FROM weapons WHERE id=?')).get(id); - if (!exists) return fail(404, { weaponError: 'Weapon not found.' }); await (await database.prepare(`UPDATE weapons SET name=?,category=?,is_exotic=?,native_tier=?,damage_type=?,rpm=?,magazine=?, reload_speed=?,accuracy=?,stability=?,exotic_talent_id=?,active=1,updated_at=? WHERE id=?`)) .run(name, category, isExotic ? 1 : 0, nativeTier, damageType, fixed.rpm, fixed.magazine, fixed.reloadSpeed, fixed.accuracy, fixed.stability, exoticTalentId, now, id); @@ -415,10 +428,8 @@ export const actions: Actions = { } await (await database.prepare('DELETE FROM weapon_tier_ranges WHERE weapon_id=?')).run(weaponId); - const sharedCoreRanges = await (await database.prepare('SELECT * FROM core_attribute_tier_ranges ORDER BY tier')).all(); for (const tier of tiers) { const core = sharedCoreRanges.find((range) => Number(range.tier) === tier.tier); - if (!core) return fail(500, { weaponError: `Global core configuration is missing T${tier.tier}.` }); await (await database.prepare(`INSERT INTO weapon_tier_ranges(weapon_id,tier,damage_min,damage_max,firepower_min, firepower_max,toughness_min,toughness_max,engineering_min,engineering_max) VALUES(?,?,?,?,?,?,?,?,?,?)`)) .run(weaponId, tier.tier, tier.damage.min, tier.damage.max, core.nominal_min, core.nominal_max, core.nominal_min, core.nominal_max, core.nominal_min, core.nominal_max); @@ -440,7 +451,12 @@ export const actions: Actions = { await (await database.prepare('INSERT INTO weapon_base_attribute_bonuses(id,weapon_id,stat_attribute_id,value,sort_order) VALUES(?,?,?,?,?)')) .run(randomUUID(), weaponId, effect.statId, effect.value, effect.order); } - await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, modSlots, talent1Ids, talent2Ids, augmentEffects, baseBonuses, exoticTalent: isExotic ? { id: exoticTalentId, name: exoticTalentName, icon: await gameDataIconMetadata(database, 'talent', String(exoticTalentId)), cooldownSeconds: exoticCooldownSeconds, durationSeconds: exoticDurationSeconds, maxStacks: exoticMaxStacks, descriptionTemplate: exoticDescriptionTemplate, triggerIds: exoticTriggerIds, effects: exoticEffects } : null } }); + await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, modSlots, talent1Ids, talent2Ids, augmentEffects, baseBonuses, exoticTalent: isExotic ? { id: exoticTalentId, name: exoticTalentName, icon: await gameDataIconMetadata(database, 'talent', String(exoticTalentId)), descriptionTemplate: exoticDescriptionTemplate, conditional: await conditionalRuleSnapshot(database, 'talent', String(exoticTalentId)) } : null } }); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } return { weaponSaved: true, weaponMode: id ? 'edit' : 'add', resultToken: randomUUID() }; }, archive: async ({ request, cookies }) => { diff --git a/src/routes/isac/weapons/+page.svelte b/src/routes/isac/weapons/+page.svelte index 64499cc..b9b1c8a 100644 --- a/src/routes/isac/weapons/+page.svelte +++ b/src/routes/isac/weapons/+page.svelte @@ -9,6 +9,8 @@ import type { GameIconName } from '$lib/game-icons'; import IconUploadField from '$lib/components/IconUploadField.svelte'; import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; + import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte'; + import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor'; let { data, form } = $props(); const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG']; const categoryOptions = categories.map((value) => ({ value, label: value, icon: value.toLowerCase() as GameIconName, iconOnly: true })); @@ -17,7 +19,11 @@ const iconName = (value: string): GameIconName => value.toLowerCase() as GameIconName; const damageTypeOptions = damageTypes.map((value) => ({ value, label: value, icon: iconName(value), iconOnly: true })); const blankTier = (tier: number) => ({ tier, damage_min: 0, damage_max: 0 }); - const blankExoticTalent = () => ({ id: '', name: '', iconUrl: '', cooldown_seconds: '', duration_seconds: '', max_stacks: '', descriptionTemplate: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] }); + const blankConditionalRule = (): ConditionalMechanicDraftV1 => ({ + version:1,simulationReadiness:'captured_only',scenarioInputs:[],pools:[], + clauses:[{key:'primary',label:'Primary',event:'weapon_hit',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'equipped_weapon',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{I:{value:0},II:{value:0}}}]}] + }); + const blankExoticTalent = () => ({ id: '', name: '', iconUrl: '', descriptionTemplate: '', conditionalRule: blankConditionalRule() }); const blank = () => ({ id: '', name: '', category: 'AR', is_exotic: 0, native_tier: '0', damage_type: 'Pierce', rpm: 0, magazine: 0, reload_speed: 0, accuracy: 0, stability: 0, exotic_talent_id: '', exoticTalent: blankExoticTalent(), active: 1, tiers: [blankTier(0), blankTier(1), blankTier(2)], modSlots: [0,1,2].map((tier) => ({ tier, slots: [] })), talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }], baseBonuses: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] }); let editing = $state(null); let handledResultToken = $state(''); @@ -29,7 +35,7 @@ return { ...blankTier(tier), ...saved }; }); const modSlots = [0,1,2].map((tier) => ({ tier, slots: [...(weapon.modSlots?.find((row: any) => Number(row.tier) === tier)?.slots ?? [])] })); - editing = { ...weapon, native_tier: String(weapon.native_tier), tiers, modSlots, talent1Ids: [...weapon.talent1Ids], talent2Ids: [...weapon.talent2Ids], augmentEffects: weapon.augmentEffects.map((effect: any) => ({ ...effect })), baseBonuses: weapon.baseBonuses.map((effect: any) => ({ ...effect })), exoticTalent: weapon.exoticTalent ? { ...weapon.exoticTalent, triggerIds: [...weapon.exoticTalent.triggerIds], effects: weapon.exoticTalent.effects.map((effect: any) => ({ ...effect })) } : blankExoticTalent() }; + editing = { ...weapon, native_tier: String(weapon.native_tier), tiers, modSlots, talent1Ids: [...weapon.talent1Ids], talent2Ids: [...weapon.talent2Ids], augmentEffects: weapon.augmentEffects.map((effect: any) => ({ ...effect })), baseBonuses: weapon.baseBonuses.map((effect: any) => ({ ...effect })), exoticTalent: weapon.exoticTalent ? { ...weapon.exoticTalent, conditionalRule: weapon.exoticTalent.conditionalRule ?? blankConditionalRule() } : blankExoticTalent() }; }; const tierAt = (tier: number) => editing.tiers.find((item: any) => Number(item.tier) === tier); const modSlotsAt = (tier: number) => editing.modSlots.find((item: any) => Number(item.tier) === tier); @@ -44,22 +50,23 @@ editing.is_exotic = isExotic ? 1 : 0; if (isExotic && !editing.exoticTalent) editing.exoticTalent = blankExoticTalent(); }; - const addExoticEffect = () => { - if (editing?.exoticTalent && editing.exoticTalent.effects.length < 12) editing.exoticTalent.effects = [...editing.exoticTalent.effects, { statId: '', valueI: '', valueII: '' }]; - }; - const removeExoticEffect = (index: number) => { - if (editing?.exoticTalent?.effects.length > 1) editing.exoticTalent.effects = editing.exoticTalent.effects.filter((_: any, effectIndex: number) => effectIndex !== index); - }; const mechanicValue = (value: string | number, singular: string) => `${value} ${Number(value) === 1 ? singular : `${singular}s`}`; - const exoticPreview = (tier: 'I' | 'II') => editing.exoticTalent.descriptionTemplate.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => { - if (expression === 'duration') return editing.exoticTalent.duration_seconds === '' ? placeholder : mechanicValue(editing.exoticTalent.duration_seconds, 'second'); - if (expression === 'max_stacks') return editing.exoticTalent.max_stacks === '' ? placeholder : mechanicValue(editing.exoticTalent.max_stacks, 'stack'); - const effect = editing.exoticTalent.effects[Number(expression) - 1]; - if (!effect) return placeholder; - const stat = data.stats.find((item) => item.id === effect.statId); - const value = tier === 'I' ? effect.valueI : effect.valueII; + const exoticOutcomeValue = (effect: any, tier: 'I' | 'II') => { + const value = effect?.variants?.[tier]?.value; + if (value === '' || value === null || value === undefined) return ''; + if (effect.kind !== 'attribute_modifier') return String(value); + const stat = data.stats.find((item) => item.id === effect.attributeId); const unit = stat?.unit === 'flat' ? '' : stat?.unit ?? ''; return unit === 'Shots' ? `${value} ${unit}` : `${value}${unit}`; + }; + const exoticPreview = (tier: 'I' | 'II') => editing.exoticTalent.descriptionTemplate.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => { + const duration = editing.exoticTalent.conditionalRule.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds; + const maxStacks = editing.exoticTalent.conditionalRule.pools.length ? Math.max(...editing.exoticTalent.conditionalRule.pools.map((pool: any) => pool.max ?? 1)) : null; + if (expression === 'duration') return duration == null ? placeholder : mechanicValue(duration, 'second'); + if (expression === 'max_stacks') return maxStacks == null ? placeholder : mechanicValue(maxStacks, 'stack'); + const available = editing.exoticTalent.conditionalRule.clauses.flatMap((clause: any) => clause.outcomes); + const value = exoticOutcomeValue(available.find((outcome: any) => outcome.descriptionSlot === Number(expression)), tier); + return value || placeholder; }); const addAugmentEffect = () => { if (editing && editing.augmentEffects.length < 2) editing.augmentEffects = [...editing.augmentEffects, { stat_attribute_id: '', value: 0, sort_order: 2 }]; @@ -106,17 +113,18 @@
{#if Number(editing.is_exotic)} - +
DEDICATED EXOTIC TALENTOWNED EXCLUSIVELY BY THIS WEAPON · TIER II VALUES REQUIRED
{editing.exoticTalent.id ? 'LINKED RECORD' : 'NEW RECORD'}
-

DESCRIPTION EXPRESSIONS · {'{duration}'} and {'{max_stacks}'} render the defined mechanics. Cooldown is displayed separately as a standardized Talent Cooldown line.

TIER I PREVIEW

{exoticPreview('I') || 'Description preview'}

TIER II PREVIEW

{exoticPreview('II') || 'Description preview'}

-
-
ACTIVATION TRIGGERSSHARED BETWEEN BOTH TIERS
{editing.exoticTalent.triggerIds.length}
-
ATTRIBUTE EFFECTSONE ROW PER TEMPLATE PLACEHOLDER
{#each editing.exoticTalent.effects as effect: any, index}
{index + 1}
{/each}
-
+
{/if}
FIXED WEAPON ATTRIBUTESUNCHANGED ACROSS TIERS
BASE HANDLING