feat: add source-owned combat mechanic programs
This commit is contained in:
@@ -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:
|
||||
|
||||
+151
-28
@@ -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,
|
||||
|
||||
+216
-49
@@ -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
|
||||
|
||||
+278
-119
@@ -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
|
||||
|
||||
+965
-12
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'
|
||||
|
||||
+4
-2
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, number>,
|
||||
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<string, number | boolean>
|
||||
: {};
|
||||
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<string, { value: number; scope: string }>();
|
||||
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<Record<MechanicEvent['kind'], number>> = {
|
||||
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<string, { value: number; scope: string }>();
|
||||
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<string>
|
||||
): 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)
|
||||
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<string, number>
|
||||
) => 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<typeof evaluateSources>) => {
|
||||
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)
|
||||
},
|
||||
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 ?? {})
|
||||
}];
|
||||
}));
|
||||
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)))
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> };
|
||||
delete draft.specialization.combatProfile;
|
||||
it('rejects a draft missing the unified combat profile', () => {
|
||||
const draft = copyDraft() as unknown as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
delete missing.calculationMode;
|
||||
expect(parseLoadoutDraft(missing)).toBeNull();
|
||||
|
||||
const invalid = copyDraft() as unknown as Record<string, unknown>;
|
||||
invalid.calculationMode = 'legacy';
|
||||
expect(parseLoadoutDraft(invalid)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a specialization that carries retired unassigned chips', () => {
|
||||
const draft = copyDraft() as unknown as { specialization: Record<string, unknown> };
|
||||
draft.specialization.unassignedChips = [];
|
||||
|
||||
+98
-25
@@ -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<CombatProfile> };
|
||||
export type ConditionalEffectSelection = {
|
||||
mode: EffectMode;
|
||||
overrides?: Partial<CombatProfile>;
|
||||
inputs?: Record<string, number | boolean>;
|
||||
};
|
||||
export type SkillActivationSelection = {
|
||||
mode: 'off' | 'on_cooldown' | 'custom_per_minute';
|
||||
usesPerMinute?: number;
|
||||
targets: number;
|
||||
};
|
||||
export type SpecializationSelection = {
|
||||
focusId: string;
|
||||
nodeRanks: Record<string, number>;
|
||||
effectStates: Record<string, ConditionalEffectSelection>;
|
||||
combatProfile: CombatProfile;
|
||||
};
|
||||
export type LoadoutDraft = {
|
||||
version: 2;
|
||||
version: 4;
|
||||
buildName: string;
|
||||
calculationMode: CalculationMode;
|
||||
gear: Record<string, GearSelection>;
|
||||
weapons: [WeaponSelection, WeaponSelection];
|
||||
chips: [ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection];
|
||||
protocol: ProtocolSelection;
|
||||
activeTalents: string[];
|
||||
conditionalEffects: Record<string, ConditionalEffectSelection>;
|
||||
combatProfile: CombatProfile;
|
||||
skillActivations: Record<string, SkillActivationSelection>;
|
||||
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<string, ConditionalEffectSelection> =>
|
||||
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<string, SkillActivationSelection> =>
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, CalculationScriptAttribute>
|
||||
) {
|
||||
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'
|
||||
];
|
||||
}
|
||||
@@ -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> = {}): 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',
|
||||
|
||||
@@ -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<WeaponCombatInput> = {
|
||||
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
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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> = {}
|
||||
): 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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, never>;
|
||||
|
||||
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<T> =
|
||||
| { ok: true; value: T }
|
||||
| { ok: false; errors: string[] };
|
||||
|
||||
export type TriggerActionDefinition = {
|
||||
key: TriggerActionKey;
|
||||
label: string;
|
||||
validate: (params: unknown) => ValidationResult<TriggerParamsByAction[TriggerActionKey]>;
|
||||
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<ConditionalOutcome['application'], 'active_modifier'>;
|
||||
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<string, unknown> =>
|
||||
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<EmptyTriggerParams> {
|
||||
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<SameTargetHitsParams> {
|
||||
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<DistinctEnemiesParams> {
|
||||
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<ConsecutiveHitsParams> {
|
||||
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<ContinuousFiringParams> {
|
||||
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<HealthThresholdParams> {
|
||||
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<number>();
|
||||
const outcomeKeys = new Set<string>();
|
||||
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<DistanceParams> {
|
||||
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<Record<TriggerActionKey, TriggerActionDefinition>> = {
|
||||
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<TriggerConfiguration> {
|
||||
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<ConditionalProgram> {
|
||||
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<string>();
|
||||
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<string, number[]>();
|
||||
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<string, number>();
|
||||
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<number>([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<ConditionalActivation>((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<ConditionalStateInterval>((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
|
||||
};
|
||||
}
|
||||
@@ -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.']
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<MechanicOutcome, 'id' | 'value'> & {
|
||||
key: string;
|
||||
descriptionSlot?: number;
|
||||
variants: Record<string, ConditionalMechanicVariantValue>;
|
||||
};
|
||||
|
||||
export type ConditionalMechanicClauseDraft = Omit<MechanicClause, 'id' | 'when' | 'outcomes'> & {
|
||||
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<string>;
|
||||
allowedRecipeKeys?: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
export type ParsedConditionalMechanicDraft = {
|
||||
draft: ConditionalMechanicDraftV1;
|
||||
programs: Record<string, MechanicProgram>;
|
||||
};
|
||||
|
||||
export type ConditionalMechanicDraftResult =
|
||||
| { ok: true; value: ParsedConditionalMechanicDraft }
|
||||
| { ok: false; errors: string[] };
|
||||
|
||||
const object = (value: unknown): value is Record<string, unknown> =>
|
||||
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<string>(MECHANIC_EVENT_KINDS);
|
||||
const phases = new Set<OutcomePhase>(['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active']);
|
||||
const outcomeKinds = new Set<MechanicOutcome['kind']>([
|
||||
'attribute_modifier',
|
||||
'direct_damage',
|
||||
'restoration',
|
||||
'resource_cost',
|
||||
'marker'
|
||||
]);
|
||||
const scopes = new Set<MechanicOutcomeScope>([
|
||||
'self',
|
||||
'equipped_weapon',
|
||||
'target',
|
||||
'allies',
|
||||
'self_and_allies',
|
||||
'assigned_ability',
|
||||
'all_skills'
|
||||
]);
|
||||
const bases = new Set<OutcomeBasis>([
|
||||
'flat',
|
||||
'max_health_percent',
|
||||
'current_health_percent',
|
||||
'weapon_base_percent'
|
||||
]);
|
||||
const resources = new Set<MechanicResource>(['health', 'armor', 'ammo']);
|
||||
const stateKinds = new Set<MechanicStateKind>([
|
||||
'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<string, unknown>) {
|
||||
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<string> | 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<string, ConditionalMechanicVariantValue> = {};
|
||||
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<string, MechanicProgram> = {};
|
||||
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<string>();
|
||||
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: []
|
||||
};
|
||||
}
|
||||
@@ -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> = {}
|
||||
): 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> = {}
|
||||
): 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> = {}
|
||||
): 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> = {}
|
||||
): 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.'
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -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<string>(MECHANIC_EVENT_KINDS);
|
||||
const object = (value: unknown): value is Record<string, unknown> =>
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, string>,
|
||||
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<typeof value> => value !== null);
|
||||
const poolKeys = new Map(parsedPools.map((pool) => [pool.id, pool.definition.key]));
|
||||
const triggerMap = new Map(
|
||||
input.triggers.map((trigger) => [text(trigger.id), trigger])
|
||||
);
|
||||
|
||||
const variantKeys = [...new Set(input.outcomes.map((row) => text(row.variant_key)).filter(Boolean))];
|
||||
let selectedVariant = options.variantKey?.trim() || '';
|
||||
if (!selectedVariant) {
|
||||
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<typeof value> => value !== null);
|
||||
const outcomesByClause = new Map<string, MechanicOutcome[]>();
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -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<TriggerActionKey, unknown> = {
|
||||
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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<MechanicProgram, 'scenarioInputs' | 'pools' | 'clauses'>;
|
||||
|
||||
export type SupportedMechanicRecipe = {
|
||||
status: 'supported';
|
||||
seed: MechanicRecipeSeed;
|
||||
};
|
||||
|
||||
export type UnsupportedMechanicRecipe = {
|
||||
status: 'unsupported';
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type MechanicRecipeResult = SupportedMechanicRecipe | UnsupportedMechanicRecipe;
|
||||
|
||||
export type MechanicRecipeDefinition<K extends TriggerActionKey = TriggerActionKey> = {
|
||||
key: K;
|
||||
createSeed: (params: TriggerParamsByAction[K]) => MechanicRecipeResult;
|
||||
};
|
||||
|
||||
export type MechanicRecipeRegistry = {
|
||||
[K in TriggerActionKey]: MechanicRecipeDefinition<K>;
|
||||
};
|
||||
|
||||
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<MechanicRecipeRegistry> = {
|
||||
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<K extends TriggerActionKey>(
|
||||
action: K,
|
||||
params: TriggerParamsByAction[K]
|
||||
): MechanicRecipeResult {
|
||||
const definition = MECHANIC_RECIPE_REGISTRY[action] as MechanicRecipeDefinition<K>;
|
||||
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);
|
||||
}
|
||||
@@ -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<string, number | boolean> = {}) => ({
|
||||
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'));
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,268 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
calculateSkillDps,
|
||||
type SkillAbilityInput,
|
||||
type SkillCalculationInput
|
||||
} from './skill.js';
|
||||
|
||||
const directAbility = (
|
||||
overrides: Partial<SkillAbilityInput> = {}
|
||||
): 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> = {}): 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'
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,553 @@
|
||||
export type SkillStatValues = Record<string, number>;
|
||||
|
||||
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<string, {
|
||||
combination: 'additive' | 'multiplicative_percent';
|
||||
cap?: number | null;
|
||||
}>;
|
||||
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<string>();
|
||||
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<SkillCombinedCalculation>(
|
||||
(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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import PseudoScript from './PseudoScript.svelte';
|
||||
|
||||
let {
|
||||
fullReferenceHref = '/isac/mechanic-reference',
|
||||
buttonLabel = 'SCRIPT REFERENCE'
|
||||
}: {
|
||||
fullReferenceHref?: string;
|
||||
buttonLabel?: string;
|
||||
} = $props();
|
||||
|
||||
let dialog: HTMLDialogElement;
|
||||
const example = [
|
||||
'WHEN weapon_hit',
|
||||
'IF same_target_hits >= 9 WITHIN 3s',
|
||||
'ADD 1 STACK TO relentless',
|
||||
'MAX 10 STACKS',
|
||||
'REFRESH 10s ON STACK',
|
||||
'APPLY weapon_damage += 3% PER STACK'
|
||||
];
|
||||
</script>
|
||||
|
||||
<button type="button" class="script-help-trigger" onclick={() => dialog.showModal()}>
|
||||
<span aria-hidden="true">?</span>{buttonLabel}
|
||||
</button>
|
||||
|
||||
<dialog bind:this={dialog} aria-labelledby="mechanic-language-help-title">
|
||||
<div class="dialog-shell">
|
||||
<header>
|
||||
<div>
|
||||
<p>ISAC SCRIPT LANGUAGE</p>
|
||||
<h2 id="mechanic-language-help-title">How calculation scripts work</h2>
|
||||
</div>
|
||||
<button class="close" type="button" aria-label="Close script reference" onclick={() => dialog.close()}>×</button>
|
||||
</header>
|
||||
<div class="help-content">
|
||||
<p>ISAC generates these scripts from structured controls. You review the script, while typed data remains the calculation source.</p>
|
||||
<dl>
|
||||
<div><dt>WHEN</dt><dd>The event that begins a mechanic branch.</dd></div>
|
||||
<div><dt>IF</dt><dd>Conditions checked against the event, Agent, target, team, or encounter.</dd></div>
|
||||
<div><dt>TRACK</dt><dd>A count, duration, scalar, mark, or stack pool shared by branches.</dd></div>
|
||||
<div><dt>ADD / SET / CONSUME</dt><dd>How a matching branch changes tracked state.</dd></div>
|
||||
<div><dt>APPLY</dt><dd>The Attribute or direct result and the phase when it takes effect.</dd></div>
|
||||
<div><dt>UNTIL</dt><dd>The expiry, reset, or transfer policy for tracked state.</dd></div>
|
||||
<div><dt>EVERY</dt><dd>An explicit periodic interval. ISAC does not infer a missing cadence.</dd></div>
|
||||
<div><dt>PHASE</dt><dd>Whether an outcome runs before state changes, after them, or while state remains active.</dd></div>
|
||||
</dl>
|
||||
<PseudoScript title="MECHANIC EXAMPLE" lines={example} compact />
|
||||
<section class="calculation-language">
|
||||
<h3>Combat calculation statements</h3>
|
||||
<p><code>TAKE</code>, <code>ADD</code>, <code>APPLY</code>, <code>WEIGHT</code>, and <code>RETURN</code> explain the ordered deterministic damage pipeline.</p>
|
||||
</section>
|
||||
</div>
|
||||
<footer>
|
||||
<a class="secondary" href={fullReferenceHref} onclick={() => dialog.close()}>OPEN FULL REFERENCE →</a>
|
||||
<button class="primary" type="button" onclick={() => dialog.close()}>RETURN TO EDITOR</button>
|
||||
</footer>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.script-help-trigger{display:inline-flex;align-items:center;gap:7px;min-height:40px;padding:0 10px;border:1px solid var(--shd-border);background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;cursor:pointer;transition:var(--shd-transition)}
|
||||
.script-help-trigger>span{display:grid;place-items:center;width:18px;height:18px;border:1px solid currentColor;border-radius:50%;font-size:11px}
|
||||
.script-help-trigger:hover,.script-help-trigger:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange)}
|
||||
.script-help-trigger:active{transform:translateY(1px);filter:brightness(.88)}
|
||||
.script-help-trigger:focus-visible,.close:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px}
|
||||
dialog{width:min(780px,calc(100vw - 24px));max-height:calc(100vh - 24px);padding:0;border:1px solid var(--shd-border-strong);background:var(--shd-surface);color:var(--shd-text);box-shadow:10px 12px 34px rgb(0 0 0 / .48)}
|
||||
dialog::backdrop{background:rgb(0 0 0 / .76)}
|
||||
.dialog-shell{display:grid;max-height:calc(100vh - 26px)}
|
||||
.dialog-shell>header{display:flex;align-items:start;justify-content:space-between;gap:var(--shd-gap-md);padding:var(--shd-gap-md);border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}
|
||||
header p{margin:0 0 3px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.1px}
|
||||
h2{margin:0;font-size:var(--shd-type-body);text-transform:uppercase}
|
||||
.close{display:grid;place-items:center;width:40px;height:40px;border:1px solid var(--shd-border);background:transparent;color:var(--shd-text);font-size:24px;cursor:pointer}
|
||||
.close:hover{border-color:var(--shd-orange);color:var(--shd-orange)}
|
||||
.help-content{display:grid;gap:var(--shd-gap-md);padding:var(--shd-gap-md);overflow:auto}
|
||||
.help-content>p,.calculation-language p{margin:0;color:var(--shd-text-muted);line-height:1.5}
|
||||
dl{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1px;margin:0;background:var(--shd-border)}
|
||||
dl>div{display:grid;grid-template-columns:100px 1fr;gap:8px;padding:9px;background:var(--shd-surface-raised)}
|
||||
dt{color:var(--shd-success);font-family:var(--shd-font-mono);font-size:var(--shd-type-supporting);font-weight:900}
|
||||
dd{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
|
||||
.calculation-language{padding:10px;border:1px solid var(--shd-border)}
|
||||
.calculation-language h3{margin:0 0 5px;font-size:var(--shd-type-body);text-transform:uppercase}
|
||||
.calculation-language code{color:var(--shd-success);font-family:var(--shd-font-mono);font-weight:900}
|
||||
footer{display:flex;justify-content:flex-end;gap:var(--shd-gap-sm);padding:var(--shd-gap-md);border-top:1px solid var(--shd-border);background:var(--shd-surface-raised)}
|
||||
footer a,footer button{display:inline-flex;align-items:center;justify-content:center;min-height:40px;padding:0 14px}
|
||||
@media(max-width:620px){.script-help-trigger{min-height:44px}dl{grid-template-columns:1fr}footer{display:grid}footer a,footer button{width:100%;min-height:44px}}
|
||||
@media(prefers-reduced-motion:reduce){.script-help-trigger{transition:none}}
|
||||
</style>
|
||||
@@ -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<string, unknown> | null);
|
||||
const proposedAbilityAttack = $derived((jsonValue(proposed.attackJson) ?? null) as Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, any>;
|
||||
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;
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="proposal-change-preview" aria-label="Proposed data comparison">
|
||||
@@ -168,7 +288,10 @@
|
||||
<div class="proposal-field-summary">
|
||||
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.current)}</strong></div>{/each}
|
||||
<div><span>ROLL POOLS</span><strong>{(currentSnapshot?.primaryOptions as any[])?.length ?? 0} PRIMARY · {(currentSnapshot?.secondaryOptions as any[])?.length ?? 0} SECONDARY</strong></div>
|
||||
<div><span>TALENT STRUCTURE</span><strong>{(currentSnapshot?.talentTriggers as any[])?.length ?? 0} TRIGGERS · {(currentSnapshot?.talentEffects as any[])?.length ?? 0} EFFECTS</strong></div>
|
||||
</div>
|
||||
<div class="proposal-structured-list">
|
||||
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
|
||||
{#each conditionalRuleLines(currentSnapshot?.conditional) as line}<p>{line}</p>{:else}<p>NO TYPED RULE CONFIGURED</p>{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="proposal-empty-state"><b>NEW SKILL CHIP</b><span>No live registry record will be replaced.</span></div>
|
||||
@@ -179,7 +302,10 @@
|
||||
<div class="proposal-field-summary">
|
||||
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.proposed)}</strong></div>{/each}
|
||||
<div><span>ROLL POOLS</span><strong>{Array.isArray(proposed.primaryStatIds) ? proposed.primaryStatIds.length : Number(Boolean(proposed.primaryStatIds))} PRIMARY · {Array.isArray(proposed.secondaryStatIds) ? proposed.secondaryStatIds.length : Number(Boolean(proposed.secondaryStatIds))} SECONDARY</strong></div>
|
||||
<div><span>TALENT STRUCTURE</span><strong>{Array.isArray(proposed.triggerIds) ? proposed.triggerIds.length : Number(Boolean(proposed.triggerIds))} TRIGGERS · {((jsonValue(proposed.talentEffects) ?? []) as any[]).length} EFFECTS</strong></div>
|
||||
</div>
|
||||
<div class="proposal-structured-list">
|
||||
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
|
||||
{#each conditionalRuleLines(jsonValue(proposed.conditionalRule)) as line}<p>{line}</p>{:else}<p>NO TYPED RULE CONFIGURED</p>{/each}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -187,11 +313,11 @@
|
||||
<div class="proposal-compare-grid calculation-proposal-preview">
|
||||
<article class="proposal-editor-preview before">
|
||||
<header><span>CURRENT LIVE · {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}</span><b>{calculationRecord ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
|
||||
{#if calculationRecord}<div class="proposal-field-summary"><div><span>NAME</span><strong>{shown(calculationRecord.name)}</strong></div><div><span>STATE</span><strong>{shown(calculationRecord.status)}</strong></div>{#if action === 'saveRevision'}<div><span>STAGES</span><strong>{(currentSnapshot?.stages as any[])?.length ?? 0}</strong></div><div><span>ATTRIBUTE PROFILES</span><strong>{(currentSnapshot?.profiles as any[])?.length ?? 0}</strong></div>{/if}</div>{:else}<div class="proposal-empty-state"><b>NEW {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}</b><span>No live registry record will be replaced.</span></div>{/if}
|
||||
{#if calculationRecord}<div class="proposal-field-summary"><div><span>NAME</span><strong>{shown(calculationRecord.name)}</strong></div><div><span>STATE</span><strong>{shown(calculationRecord.status)}</strong></div>{#if action === 'saveRevision'}<div><span>STAGES</span><strong>{(currentSnapshot?.stages as any[])?.length ?? 0}</strong></div><div><span>WEAPON PROFILES</span><strong>{(currentSnapshot?.profiles as any[])?.length ?? 0}</strong></div><div><span>SKILL PROFILES</span><strong>{(currentSnapshot?.skillProfiles as any[])?.length ?? 0}</strong></div>{/if}</div>{:else}<div class="proposal-empty-state"><b>NEW {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}</b><span>No live registry record will be replaced.</span></div>{/if}
|
||||
</article>
|
||||
<article class="proposal-editor-preview after">
|
||||
<header><span>PROPOSED · {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}</span><b>AFTER</b></header>
|
||||
<div class="proposal-field-summary"><div><span>NAME</span><strong>{shown(proposed.name)}</strong></div>{#if action === 'saveRevision'}<div><span>STAGES</span><strong>{proposedCalculationStages.length}</strong></div><div><span>ATTRIBUTE PROFILES</span><strong>{proposedCalculationProfiles.length}</strong></div><div><span>BASE REVISION</span><strong>{shown(proposed.basedOnRevisionId)}</strong></div>{:else}<div><span>STATE</span><strong>{shown(proposed.status)}</strong></div><div><span>MEASURED WEAPON</span><strong>{proposed.selectedWeapon === '1' ? 'SECONDARY' : 'PRIMARY'}</strong></div><div><span>CORE OBSERVATIONS</span><strong>{['bodyHit','criticalBodyHit','headshot','criticalHeadshot','magazineSeconds','reloadSeconds'].filter((field) => proposed[field]).length} / 6</strong></div>{/if}</div>
|
||||
<div class="proposal-field-summary"><div><span>NAME</span><strong>{shown(proposed.name)}</strong></div>{#if action === 'saveRevision'}<div><span>STAGES</span><strong>{proposedCalculationStages.length}</strong></div><div><span>WEAPON PROFILES</span><strong>{proposedCalculationProfiles.length}</strong></div><div><span>SKILL PROFILES</span><strong>{proposedSkillCalculationProfiles.length}</strong></div><div><span>BASE REVISION</span><strong>{shown(proposed.basedOnRevisionId)}</strong></div>{:else}<div><span>STATE</span><strong>{shown(proposed.status)}</strong></div><div><span>MEASURED WEAPON</span><strong>{proposed.selectedWeapon === '1' ? 'SECONDARY' : 'PRIMARY'}</strong></div><div><span>CORE OBSERVATIONS</span><strong>{['bodyHit','criticalBodyHit','headshot','criticalHeadshot','magazineSeconds','reloadSeconds'].filter((field) => proposed[field]).length} / 6</strong></div>{/if}</div>
|
||||
{#if action === 'saveRevision'}<div class="proposal-rank-preview">{#each proposedCalculationStages as stage}<section><header><span>{stage.label}</span><b>{stage.operator?.replaceAll('_',' ').toUpperCase()}</b></header><p>ORDER {stage.displayOrder} · {stage.lockedPosition?.toUpperCase() ?? 'REORDERABLE'}</p></section>{/each}</div>{/if}
|
||||
</article>
|
||||
</div>
|
||||
@@ -210,6 +336,10 @@
|
||||
<header><span>STRUCTURED EFFECTS</span><b>{currentAbilityEffects.length}</b></header>
|
||||
{#each currentAbilityEffects as effect}<p>{effectSummary(effect)}</p>{:else}<p>NO STRUCTURED EFFECTS</p>{/each}
|
||||
</div>
|
||||
<div class="proposal-structured-list">
|
||||
<header><span>INTRINSIC ATTACK PROFILE</span><b>{currentAbilityAttack ? 'CONFIGURED' : 'NONE'}</b></header>
|
||||
{#each attackProfileLines(currentAbilityAttack) as line}<p>{line}</p>{:else}<p>NO INTRINSIC ATTACK PROFILE</p>{/each}
|
||||
</div>
|
||||
{:else if action === 'saveNode'}
|
||||
<div class="proposal-rank-preview">
|
||||
{#each currentNodeRanks as rank}
|
||||
@@ -239,6 +369,10 @@
|
||||
<header><span>STRUCTURED EFFECTS</span><b>{proposedEffects.length}</b></header>
|
||||
{#each proposedEffects as effect}<p>{effectSummary(effect)}</p>{:else}<p>NO STRUCTURED EFFECTS</p>{/each}
|
||||
</div>
|
||||
<div class="proposal-structured-list">
|
||||
<header><span>INTRINSIC ATTACK PROFILE</span><b>{attackProfileLines(proposedAbilityAttack).length ? 'CONFIGURED' : 'NONE'}</b></header>
|
||||
{#each attackProfileLines(proposedAbilityAttack) as line}<p>{line}</p>{:else}<p>NO INTRINSIC ATTACK PROFILE</p>{/each}
|
||||
</div>
|
||||
{:else if action === 'saveNode'}
|
||||
<div class="proposal-rank-preview">
|
||||
{#each proposedRanks as rank}
|
||||
@@ -246,7 +380,7 @@
|
||||
<header><span>RANK {rank.rank}</span><b>{rank.effects?.length ?? 0} STATIC · {rank.combatEffects?.length ?? 0} CONDITIONAL</b></header>
|
||||
<p>{rank.description || 'NO RANK DESCRIPTION'}</p>
|
||||
{#each rank.effects ?? [] as effect}<small>{effectSummary(effect)}</small>{/each}
|
||||
{#each rank.combatEffects ?? [] as effect}<small>{effect.effectId} · {effect.triggerCount} HITS / {effect.triggerWindowSeconds}s · {effect.maxStacks} STACKS · {effect.modifiers?.length ?? 0} MODIFIERS</small>{/each}
|
||||
{#each rank.combatEffects ?? [] as effect}<small>{conditionalRuleSummary(effect)}</small>{/each}
|
||||
<small>{Object.entries(rank.costs ?? {}).filter(([, quantity]) => Number(quantity) > 0).map(([resource, quantity]) => `${resource} × ${quantity}`).join(' · ') || 'CREDITS ONLY'}</small>
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
title = 'GENERATED SCRIPT',
|
||||
lines,
|
||||
label = title,
|
||||
compact = false,
|
||||
readiness = 'READY'
|
||||
}: {
|
||||
title?: string;
|
||||
lines: string[];
|
||||
label?: string;
|
||||
compact?: boolean;
|
||||
readiness?: 'READY' | 'REQUIRES ASSUMPTIONS' | 'CAPTURED ONLY';
|
||||
} = $props();
|
||||
|
||||
const keyword = (line: string) => line.trim().split(/\s+/, 1)[0] ?? '';
|
||||
const remainder = (line: string) => line.trim().slice(keyword(line).length).trimStart();
|
||||
</script>
|
||||
|
||||
<section class:compact class="pseudo-script" aria-label={label}>
|
||||
<header>
|
||||
<span aria-hidden="true">▤</span>
|
||||
<strong>{title}</strong>
|
||||
<small>{readiness} · READ-ONLY · GENERATED FROM TYPED DATA</small>
|
||||
</header>
|
||||
<ol>
|
||||
{#each lines as line}
|
||||
<li class:blank={!line.trim()}>
|
||||
{#if line.trim()}
|
||||
<code><b>{keyword(line)}</b>{#if remainder(line)}{' '}<span>{remainder(line)}</span>{/if}</code>
|
||||
{:else}
|
||||
<span aria-hidden="true"> </span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.pseudo-script{min-width:0;border:1px solid var(--shd-model-border);border-left:2px solid var(--shd-info);background:var(--shd-model-surface)}
|
||||
header{display:flex;align-items:center;gap:7px;min-height:38px;padding:6px 9px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}
|
||||
header>span{color:var(--shd-success);font-size:var(--shd-type-body)}
|
||||
header strong{color:var(--shd-text);font-size:var(--shd-type-telemetry);letter-spacing:.9px}
|
||||
header small{margin-left:auto;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
|
||||
ol{display:grid;gap:2px;margin:0;padding:8px 9px 9px 3.4rem;counter-reset:script-line;color:var(--shd-text-dim)}
|
||||
li{min-height:1.45em;padding-left:8px;border-left:1px solid var(--shd-border-soft);font-family:var(--shd-font-mono);font-size:var(--shd-type-supporting);line-height:1.45}
|
||||
li::marker{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-variant-numeric:tabular-nums}
|
||||
li.blank{min-height:.55em}
|
||||
code{font:inherit;color:var(--shd-text);white-space:pre-wrap;overflow-wrap:anywhere}
|
||||
code b{color:var(--shd-success);font-weight:900}
|
||||
code span{color:var(--shd-text)}
|
||||
.compact header small{display:none}.compact ol{max-height:260px;overflow:auto}
|
||||
@media(max-width:620px){header{align-items:flex-start;flex-wrap:wrap}header small{flex-basis:100%;margin-left:24px}ol{padding-left:2.8rem}li{font-size:var(--shd-type-telemetry)}}
|
||||
</style>
|
||||
@@ -1,41 +1,92 @@
|
||||
<script lang="ts">
|
||||
import RangeValueControl from '$lib/components/RangeValueControl.svelte';
|
||||
|
||||
export type CombatProfile = {
|
||||
encounterSeconds: number;
|
||||
hitRate: number;
|
||||
headshotRate: number;
|
||||
firingActivity: number;
|
||||
};
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import type { CalculationMode, CombatProfile } from '$lib/builder/loadout';
|
||||
export type { CalculationMode, CombatProfile } from '$lib/builder/loadout';
|
||||
|
||||
let {
|
||||
mode = $bindable<CalculationMode>(),
|
||||
profile = $bindable<CombatProfile>(),
|
||||
disabled = false
|
||||
}: {
|
||||
mode: CalculationMode;
|
||||
profile: CombatProfile;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
const modes = [
|
||||
{ value: 'maximum', label: 'MAXIMUM POTENTIAL' },
|
||||
{ value: 'custom', label: 'CUSTOM SCENARIO' }
|
||||
];
|
||||
|
||||
function setMode(values: string[]) {
|
||||
mode = values[0] as CalculationMode;
|
||||
}
|
||||
|
||||
function update(key: keyof CombatProfile, value: number) {
|
||||
profile = { ...profile, [key]: value };
|
||||
}
|
||||
|
||||
function setExtraHealth(values: string[]) {
|
||||
profile = { ...profile, extraHealthActive: values[0] === 'active' };
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="combat-assumptions" aria-labelledby="combat-assumptions-title">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">COMBAT PROFILE</p>
|
||||
<h3 id="combat-assumptions-title">Encounter assumptions</h3>
|
||||
<p class="eyebrow">CALCULATION MODEL</p>
|
||||
<h3 id="combat-assumptions-title">Combat conditions</h3>
|
||||
</div>
|
||||
<small>MODELED, NOT LIVE COMBAT</small>
|
||||
<small>{mode === 'maximum' ? 'BEST-CASE OUTPUT' : 'MODELED SCENARIO'}</small>
|
||||
</div>
|
||||
<p class="supporting">{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.'}</p>
|
||||
<fieldset class="model-control" disabled={disabled}>
|
||||
<SegmentedChoice
|
||||
legend="DAMAGE CALCULATION MODEL"
|
||||
name="builder-calculation-model"
|
||||
options={modes}
|
||||
value={mode}
|
||||
columns={2}
|
||||
onchange={setMode}
|
||||
required
|
||||
/>
|
||||
</fieldset>
|
||||
{#if mode === 'custom'}
|
||||
<div class="custom-scenario" id="custom-combat-scenario">
|
||||
<div class="custom-head">
|
||||
<strong>CUSTOM SCENARIO INPUTS</strong>
|
||||
<span>Saved with this loadout</span>
|
||||
</div>
|
||||
<p class="supporting">These assumptions drive encounter-average calculations. Individual effects can override them when needed.</p>
|
||||
<fieldset class="assumption-grid" disabled={disabled}>
|
||||
<RangeValueControl label="Encounter duration" min={10} max={300} step={5} value={profile.encounterSeconds} onchange={(value) => update('encounterSeconds', value)} />
|
||||
<RangeValueControl label="Hit rate" min={0} max={100} value={profile.hitRate} onchange={(value) => update('hitRate', value)} />
|
||||
<RangeValueControl label="Headshot rate" min={0} max={100} value={profile.headshotRate} onchange={(value) => update('headshotRate', value)} />
|
||||
<RangeValueControl label="Firing activity" min={0} max={100} value={profile.firingActivity} onchange={(value) => update('firingActivity', value)} />
|
||||
<RangeValueControl label="Shots per burst" min={1} max={200} step={1} value={profile.shotsPerBurst} onchange={(value) => update('shotsPerBurst', value)} />
|
||||
<RangeValueControl label="Pause between bursts" min={0} max={15} step={0.1} value={profile.burstPauseSeconds} onchange={(value) => update('burstPauseSeconds', value)} />
|
||||
<RangeValueControl label="Time on the same target" min={0.5} max={60} step={0.5} value={profile.sameTargetSeconds} onchange={(value) => update('sameTargetSeconds', value)} />
|
||||
<RangeValueControl label="Distinct targets per minute" min={0} max={120} step={1} value={profile.distinctTargetsPerMinute} onchange={(value) => update('distinctTargetsPerMinute', value)} />
|
||||
<RangeValueControl label="Kills per minute" min={0} max={60} step={1} value={profile.killsPerMinute} onchange={(value) => update('killsPerMinute', value)} />
|
||||
<RangeValueControl label="Headshot kill share" min={0} max={100} step={1} value={profile.headshotKillRate} onchange={(value) => update('headshotKillRate', value)} />
|
||||
<RangeValueControl label="Damage events per minute" min={0} max={120} step={1} value={profile.damageTakenPerMinute} onchange={(value) => update('damageTakenPerMinute', value)} />
|
||||
<RangeValueControl label="Weapon swaps per minute" min={0} max={30} step={1} value={profile.swapsPerMinute} onchange={(value) => update('swapsPerMinute', value)} />
|
||||
<RangeValueControl label="Target distance" min={0} max={100} step={1} value={profile.targetDistanceMeters} onchange={(value) => update('targetDistanceMeters', value)} />
|
||||
<RangeValueControl label="Agent health" min={1} max={100} step={1} value={profile.healthPercent} onchange={(value) => update('healthPercent', value)} />
|
||||
<SegmentedChoice
|
||||
legend="EXTRA HEALTH"
|
||||
name="combat-extra-health"
|
||||
options={[{ value: 'inactive', label: 'INACTIVE' }, { value: 'active', label: 'ACTIVE' }]}
|
||||
value={profile.extraHealthActive ? 'active' : 'inactive'}
|
||||
onchange={setExtraHealth}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="maximum-model" role="status">
|
||||
<strong>MAXIMUM POTENTIAL ACTIVE</strong>
|
||||
<span>Effect ramp-up and encounter downtime are intentionally excluded.</span>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@@ -45,6 +96,15 @@
|
||||
h3{margin:0;color:var(--shd-text);font-size:var(--shd-type-body);text-transform:uppercase;letter-spacing:.7px}
|
||||
.section-head small{padding:4px 6px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;white-space:nowrap}
|
||||
.supporting{margin:var(--shd-gap-sm) 0 var(--shd-gap-md);color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}
|
||||
.model-control{margin:0;padding:0;border:0}
|
||||
.custom-scenario{display:grid;gap:var(--shd-gap-md);margin-top:var(--shd-gap-md);padding-top:var(--shd-gap-md);border-top:1px solid var(--shd-border-soft)}
|
||||
.custom-head{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm)}
|
||||
.custom-head strong,.custom-head span,.maximum-model strong,.maximum-model span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
|
||||
.custom-head strong,.maximum-model strong{color:var(--shd-text)}
|
||||
.custom-head span,.maximum-model span{color:var(--shd-text-muted)}
|
||||
.assumption-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--shd-gap-md);margin:0;padding:0;border:0}.assumption-grid:disabled{opacity:.55}
|
||||
.maximum-model{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-md);margin-top:var(--shd-gap-md);padding:var(--shd-gap-sm);border:1px solid #74552a;background:#1d180e}
|
||||
.maximum-model strong{color:var(--shd-orange)}
|
||||
@media(max-width:620px){.section-head{display:grid}.section-head small{width:max-content}.assumption-grid{grid-template-columns:1fr}}
|
||||
@media(max-width:620px){.custom-head,.maximum-model{align-items:start;flex-direction:column}}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
<script lang="ts">
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import RangeValueControl from '$lib/components/RangeValueControl.svelte';
|
||||
import type { CombatProfile } from './CombatAssumptions.svelte';
|
||||
|
||||
export type EffectMode = 'off' | 'peak' | 'encounter';
|
||||
export type ConditionalEffectState = {
|
||||
mode: EffectMode;
|
||||
overrides?: Partial<CombatProfile>;
|
||||
};
|
||||
import type { CalculationMode, CombatProfile, ConditionalEffectSelection, EffectMode } from '$lib/builder/loadout';
|
||||
import type { ScenarioInputDefinition } from '$lib/calculations/mechanic';
|
||||
export type ConditionalEffectState = ConditionalEffectSelection;
|
||||
|
||||
let {
|
||||
id,
|
||||
@@ -15,7 +11,9 @@
|
||||
description,
|
||||
facts,
|
||||
appliedState,
|
||||
scenarioInputs = [],
|
||||
effectState = $bindable<ConditionalEffectState>(),
|
||||
calculationMode = 'custom',
|
||||
disabled = false
|
||||
}: {
|
||||
id: string;
|
||||
@@ -23,7 +21,9 @@
|
||||
description?: string;
|
||||
facts: string[];
|
||||
appliedState: string;
|
||||
scenarioInputs?: ScenarioInputDefinition[];
|
||||
effectState: ConditionalEffectState;
|
||||
calculationMode?: CalculationMode;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
@@ -42,8 +42,15 @@
|
||||
effectState = { ...effectState, overrides: { ...effectState.overrides, [key]: value } };
|
||||
}
|
||||
|
||||
function updateScenarioInput(key: string, value: number | boolean) {
|
||||
effectState = {
|
||||
...effectState,
|
||||
inputs: { ...effectState.inputs, [key]: value }
|
||||
};
|
||||
}
|
||||
|
||||
function clearOverrides() {
|
||||
effectState = { ...effectState, overrides: undefined };
|
||||
effectState = { ...effectState, overrides: undefined, inputs: undefined };
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -59,20 +66,51 @@
|
||||
<div class="facts" aria-label={`${name} facts`}>
|
||||
{#each facts as fact}<span>{fact}</span>{/each}
|
||||
</div>
|
||||
{#if calculationMode === 'maximum'}
|
||||
<div class="maximum-effect">
|
||||
<strong>FULL EFFECT APPLIED</strong>
|
||||
<span>Maximum stacks · best-case uptime</span>
|
||||
</div>
|
||||
{:else}
|
||||
<fieldset class="mode-control" disabled={disabled}>
|
||||
<SegmentedChoice legend={`${name} calculation mode`} name={`${id}-mode`} options={modes} value={effectState.mode} columns={3} onchange={setMode} />
|
||||
</fieldset>
|
||||
{#if effectState.mode === 'encounter'}
|
||||
{/if}
|
||||
{#if calculationMode === 'custom' && effectState.mode === 'encounter'}
|
||||
<div class="advanced-head">
|
||||
<button type="button" aria-expanded={advanced} aria-controls={`${id}-overrides`} onclick={() => advanced = !advanced} disabled={disabled}>
|
||||
{advanced ? 'HIDE' : 'ADVANCED'} OVERRIDES <span aria-hidden="true">{advanced ? '−' : '+'}</span>
|
||||
</button>
|
||||
{#if effectState.overrides}<button class="clear" type="button" onclick={clearOverrides} disabled={disabled}>RESET</button>{/if}
|
||||
{#if effectState.overrides || effectState.inputs}<button class="clear" type="button" onclick={clearOverrides} disabled={disabled}>RESET</button>{/if}
|
||||
</div>
|
||||
{#if advanced}
|
||||
<div class="override-grid" id={`${id}-overrides`}>
|
||||
<RangeValueControl label="Hit rate" min={0} max={100} value={effectState.overrides?.hitRate ?? 100} onchange={(value) => updateOverride('hitRate', value)} />
|
||||
<RangeValueControl label="Firing activity" min={0} max={100} value={effectState.overrides?.firingActivity ?? 100} onchange={(value) => updateOverride('firingActivity', value)} />
|
||||
<RangeValueControl label="Shots per burst" min={1} max={200} step={1} value={effectState.overrides?.shotsPerBurst ?? 30} onchange={(value) => updateOverride('shotsPerBurst', value)} />
|
||||
<RangeValueControl label="Pause between bursts" min={0} max={15} step={0.1} value={effectState.overrides?.burstPauseSeconds ?? 0} onchange={(value) => updateOverride('burstPauseSeconds', value)} />
|
||||
{#each scenarioInputs as input}
|
||||
<div class="program-override">
|
||||
{#if input.kind === 'toggle'}
|
||||
<SegmentedChoice
|
||||
legend={input.label}
|
||||
name={`${id}-scenario-${input.key}`}
|
||||
options={[{ value: 'false', label: 'OFF' }, { value: 'true', label: 'ON' }]}
|
||||
value={String(effectState.inputs?.[input.key] ?? input.defaultValue ?? false)}
|
||||
onchange={(values) => updateScenarioInput(input.key, values[0] === 'true')}
|
||||
/>
|
||||
{:else}
|
||||
<RangeValueControl
|
||||
label={input.label}
|
||||
min={input.min ?? 0}
|
||||
max={input.max ?? 100}
|
||||
step={input.kind === 'slider' ? 1 : 0.1}
|
||||
value={Number(effectState.inputs?.[input.key] ?? input.defaultValue ?? input.min ?? 0)}
|
||||
onchange={(value) => updateScenarioInput(input.key, value)}
|
||||
/>
|
||||
{/if}
|
||||
{#if input.description}<small>{input.description}</small>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/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}}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import type { SkillCalculation } from '$lib/calculations/skill';
|
||||
|
||||
let {
|
||||
result,
|
||||
abilityNames = {}
|
||||
}: {
|
||||
result: SkillCalculation;
|
||||
abilityNames?: Record<string, string>;
|
||||
} = $props();
|
||||
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });
|
||||
</script>
|
||||
|
||||
<article class:incomplete={result.status === 'INSUFFICIENT_DATA'} class="skill-calculation" aria-live="polite">
|
||||
<header>
|
||||
<div><p>SKILL OUTPUT</p><h3>Specialization abilities</h3></div>
|
||||
<b>{result.status === 'OK' ? 'CALCULATED' : 'INSUFFICIENT DATA'}</b>
|
||||
</header>
|
||||
{#if result.status === 'INSUFFICIENT_DATA'}
|
||||
<div class="missing">
|
||||
<strong>Skill calculation unavailable</strong>
|
||||
<span>ISAC requires the following data:</span>
|
||||
<ul>{#each result.missing as item}<li>{item.replaceAll(':', ' · ')}</li>{/each}</ul>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="combined">
|
||||
<span><small>SINGLE-TARGET DPS</small><b>{number.format(result.combined.singleTargetDps)}</b></span>
|
||||
<span><small>ALL-TARGET DPS</small><b>{number.format(result.combined.aggregateDps)}</b></span>
|
||||
<span><small>ENCOUNTER DAMAGE</small><b>{number.format(result.combined.aggregateEncounterDamage)}</b></span>
|
||||
</div>
|
||||
<div class="ability-results">
|
||||
{#each result.abilities as ability}
|
||||
<div class:disabled={!ability.enabled}>
|
||||
<strong>{abilityNames[ability.abilityId] ?? ability.abilityId}</strong>
|
||||
<span>{ability.enabled ? `${ability.activationCount} ACTIVATIONS` : 'OFF'}</span>
|
||||
<small>{number.format(ability.damage.singleTargetDps)} SINGLE DPS</small>
|
||||
<small>{number.format(ability.damage.aggregateDps)} TOTAL DPS</small>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<footer>SKILLS CAN CRIT AND MULTI-SHOT · SKILLS DO NOT HEADSHOT · ENCOUNTER TRIGGERS FOLLOW THE PRIMARY WEAPON TIMELINE</footer>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.skill-calculation{display:grid;gap:var(--shd-gap-md);padding:var(--shd-gap-md);border:1px solid var(--shd-border);border-top:2px solid var(--shd-info);background:var(--shd-surface)}
|
||||
.skill-calculation.incomplete{border-top-color:var(--shd-danger)}
|
||||
header{display:flex;justify-content:space-between;align-items:start;gap:var(--shd-gap-md)}
|
||||
header p,h3{margin:0}header p{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.1px}h3{text-transform:uppercase;font-size:var(--shd-type-body);letter-spacing:.7px}
|
||||
header>b{padding:5px 7px;border:1px solid var(--shd-info);color:var(--shd-info);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.incomplete header>b{border-color:var(--shd-danger);color:var(--shd-danger)}
|
||||
.combined{display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--shd-border)}.combined span{display:grid;gap:4px;padding:9px;border-left:1px solid var(--shd-border)}.combined span:first-child{border-left:0}.combined b{color:var(--shd-info);font-size:20px}
|
||||
small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
|
||||
.ability-results{display:grid;border:1px solid var(--shd-border)}.ability-results>div{display:grid;grid-template-columns:minmax(140px,1fr) repeat(3,auto);gap:var(--shd-gap-sm);padding:7px 8px;border-top:1px solid var(--shd-border);align-items:center}.ability-results>div:first-child{border-top:0}.ability-results>div.disabled{opacity:.55}.ability-results span{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}
|
||||
.missing{display:grid;gap:4px;padding:10px;border:1px solid var(--shd-danger);border-left-width:2px;background:#1d1212;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.missing strong{color:var(--shd-danger);text-transform:uppercase}.missing ul{margin:2px 0 0;padding-left:18px}
|
||||
footer{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:.6px}
|
||||
@media(max-width:700px){header,.combined{display:grid;grid-template-columns:1fr}.combined span{border-left:0;border-top:1px solid var(--shd-border)}.combined span:first-child{border-top:0}.ability-results>div{grid-template-columns:1fr 1fr}.ability-results strong{grid-column:1/-1}}
|
||||
</style>
|
||||
@@ -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 @@
|
||||
<span>{effect.mode.toUpperCase()}</span>
|
||||
<small>AVG {effect.averageStacks.toFixed(1)} / {effect.maxStacks}</small>
|
||||
<small>PEAK {effect.maxStacksReached} · END {effect.stacksAtEnd}</small>
|
||||
<small>{(effect.uptimePercent ?? 0).toFixed(0)}% UPTIME · {effect.activationCount ?? 0} PROCS</small>
|
||||
<em>{effect.mode === 'off' ? 'EXCLUDED' : effect.buildUpSeconds === null ? 'MAX NOT REACHED' : `MAX IN ${effect.buildUpSeconds.toFixed(1)}s`}</em>
|
||||
<b class:maintained={effect.maintained}>{effect.maintained ? 'MAINTAINED' : 'NOT MAINTAINED'}</b>
|
||||
</div>
|
||||
@@ -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}}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
<script lang="ts">
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
|
||||
type RegistryItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
unit?: string;
|
||||
pending?: boolean;
|
||||
proposerName?: string;
|
||||
};
|
||||
|
||||
export type ConditionalApplication =
|
||||
| 'active_modifier'
|
||||
| 'direct_damage'
|
||||
| 'restore_health'
|
||||
| 'restore_armor'
|
||||
| 'restore_ammo';
|
||||
|
||||
export type ConditionalScope =
|
||||
| 'self'
|
||||
| 'equipped_weapon'
|
||||
| 'target'
|
||||
| 'allies'
|
||||
| 'self_and_allies'
|
||||
| 'assigned_ability'
|
||||
| 'all_skills';
|
||||
|
||||
export type ConditionalOutcomeDraft = {
|
||||
id?: string;
|
||||
application: ConditionalApplication;
|
||||
scope: ConditionalScope;
|
||||
statId: string;
|
||||
damageBasis: 'flat' | 'weapon_base_percent';
|
||||
values: Record<string, number | string | null | undefined>;
|
||||
};
|
||||
|
||||
export type ConditionalValueColumn = {
|
||||
key: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
let {
|
||||
outcomes = $bindable([]),
|
||||
attributes,
|
||||
valueColumns = [
|
||||
{ key: 'tierI', label: 'TIER I', required: true },
|
||||
{ key: 'tierII', label: 'TIER II', required: true }
|
||||
],
|
||||
idPrefix = 'conditional-outcome',
|
||||
addLabel = 'ADD OUTCOME',
|
||||
maxOutcomes = 12,
|
||||
allowAssignedAbility = false
|
||||
}: {
|
||||
outcomes?: ConditionalOutcomeDraft[];
|
||||
attributes: RegistryItem[];
|
||||
valueColumns?: ConditionalValueColumn[];
|
||||
idPrefix?: string;
|
||||
addLabel?: string;
|
||||
maxOutcomes?: number;
|
||||
allowAssignedAbility?: boolean;
|
||||
} = $props();
|
||||
|
||||
const applicationOptions = [
|
||||
{ value: 'active_modifier', label: 'ATTRIBUTE' },
|
||||
{ value: 'direct_damage', label: 'DIRECT DAMAGE' },
|
||||
{ value: 'restore_health', label: 'RESTORE HEALTH' },
|
||||
{ value: 'restore_armor', label: 'RESTORE ARMOR' },
|
||||
{ value: 'restore_ammo', label: 'RESTORE AMMO' }
|
||||
];
|
||||
const scopeOptions = $derived([
|
||||
{ id: 'self', name: 'Self' },
|
||||
{ id: 'equipped_weapon', name: 'Equipped weapon' },
|
||||
{ id: 'target', name: 'Target' },
|
||||
{ id: 'allies', name: 'Allies' },
|
||||
{ id: 'self_and_allies', name: 'Self and allies' },
|
||||
...(allowAssignedAbility ? [{ id: 'assigned_ability', name: 'Assigned ability' }] : []),
|
||||
{ id: 'all_skills', name: 'All skills' }
|
||||
]);
|
||||
const damageBasisOptions = [
|
||||
{ value: 'flat', label: 'FLAT' },
|
||||
{ value: 'weapon_base_percent', label: '% WEAPON BASE' }
|
||||
];
|
||||
|
||||
function blankOutcome(): ConditionalOutcomeDraft {
|
||||
return {
|
||||
application: 'active_modifier',
|
||||
scope: 'self',
|
||||
statId: '',
|
||||
damageBasis: 'flat',
|
||||
values: Object.fromEntries(valueColumns.map((column) => [column.key, '']))
|
||||
};
|
||||
}
|
||||
|
||||
function setApplication(index: number, application: string) {
|
||||
const current = outcomes[index];
|
||||
outcomes = outcomes.map((outcome, outcomeIndex) =>
|
||||
outcomeIndex === index
|
||||
? {
|
||||
...current,
|
||||
application: application as ConditionalApplication,
|
||||
statId: application === 'active_modifier' ? current.statId : '',
|
||||
damageBasis:
|
||||
application === 'direct_damage' ? current.damageBasis || 'flat' : 'flat'
|
||||
}
|
||||
: outcome
|
||||
);
|
||||
}
|
||||
|
||||
function setValue(index: number, key: string, rawValue: string) {
|
||||
const parsed = rawValue === '' ? '' : Number(rawValue);
|
||||
outcomes = outcomes.map((outcome, outcomeIndex) =>
|
||||
outcomeIndex === index
|
||||
? { ...outcome, values: { ...outcome.values, [key]: parsed } }
|
||||
: outcome
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="conditional-outcome-editor">
|
||||
{#each outcomes as outcome, index}
|
||||
<section class="outcome-row" aria-labelledby={`${idPrefix}-${index}-title`}>
|
||||
<header>
|
||||
<strong id={`${idPrefix}-${index}-title`}>OUTCOME {String(index + 1).padStart(2, '0')}</strong>
|
||||
<button
|
||||
type="button"
|
||||
class="remove-outcome"
|
||||
aria-label={`Remove outcome ${index + 1}`}
|
||||
onclick={() => (outcomes = outcomes.filter((_, outcomeIndex) => outcomeIndex !== index))}
|
||||
>
|
||||
REMOVE
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="outcome-grid">
|
||||
<SegmentedChoice
|
||||
legend="APPLICATION"
|
||||
name={`${idPrefix}-${index}-application`}
|
||||
options={applicationOptions}
|
||||
value={outcome.application}
|
||||
onchange={(values) => setApplication(index, values[0])}
|
||||
required
|
||||
/>
|
||||
|
||||
<div class="field assignment-field">
|
||||
<span>APPLIES TO</span>
|
||||
<SearchableAssignment
|
||||
items={scopeOptions}
|
||||
mode="single"
|
||||
compact
|
||||
subjectLabel="EFFECT SCOPE"
|
||||
searchPlaceholder="Search effect scopes…"
|
||||
emptySelectionLabel="NO EFFECT SCOPE SELECTED"
|
||||
bind:value={outcome.scope}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if outcome.application === 'active_modifier'}
|
||||
<div class="field assignment-field">
|
||||
<span>ATTRIBUTE</span>
|
||||
<SearchableAssignment
|
||||
items={attributes}
|
||||
mode="single"
|
||||
compact
|
||||
subjectLabel="ATTRIBUTE"
|
||||
searchPlaceholder="Search Attributes…"
|
||||
emptySelectionLabel="NO ATTRIBUTE SELECTED"
|
||||
bind:value={outcome.statId}
|
||||
/>
|
||||
</div>
|
||||
{:else if outcome.application === 'direct_damage'}
|
||||
<SegmentedChoice
|
||||
legend="DAMAGE BASIS"
|
||||
name={`${idPrefix}-${index}-damage-basis`}
|
||||
options={damageBasisOptions}
|
||||
bind:value={outcome.damageBasis}
|
||||
required
|
||||
/>
|
||||
{:else}
|
||||
<div class="event-note">
|
||||
<strong>INSTANT EVENT</strong>
|
||||
<span>Tracked per proc and across the encounter. It does not become a permanent Attribute.</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="outcome-values" style={`--outcome-value-columns:${Math.max(valueColumns.length, 1)}`}>
|
||||
{#each valueColumns as column}
|
||||
<label class="field">
|
||||
{column.label} VALUE
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={outcome.values[column.key] ?? ''}
|
||||
required={column.required ?? false}
|
||||
aria-label={`${column.label} value for outcome ${index + 1}`}
|
||||
oninput={(event) => setValue(index, column.key, event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
<div class="outcome-empty">
|
||||
<strong>NO CALCULATION OUTCOMES</strong>
|
||||
<span>Add at least one outcome before this conditional mechanic can affect a build.</span>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="secondary add-outcome"
|
||||
disabled={outcomes.length >= maxOutcomes}
|
||||
onclick={() => (outcomes = [...outcomes, blankOutcome()])}
|
||||
>
|
||||
{addLabel} +
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.conditional-outcome-editor {
|
||||
display: grid;
|
||||
gap: var(--shd-gap-sm);
|
||||
}
|
||||
|
||||
.outcome-row {
|
||||
border: 1px solid var(--shd-border);
|
||||
background: var(--shd-surface);
|
||||
}
|
||||
|
||||
.outcome-row > header {
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--shd-gap-sm);
|
||||
padding: 5px 8px;
|
||||
border-bottom: 1px solid var(--shd-border);
|
||||
background: var(--shd-surface-raised);
|
||||
}
|
||||
|
||||
.outcome-row > header strong {
|
||||
color: var(--shd-orange);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.remove-outcome {
|
||||
min-height: 28px;
|
||||
padding: 0 7px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--shd-danger);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
transition: var(--shd-transition);
|
||||
}
|
||||
|
||||
.remove-outcome:hover,
|
||||
.remove-outcome:focus-visible {
|
||||
border-color: var(--shd-danger);
|
||||
}
|
||||
|
||||
.remove-outcome:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.remove-outcome:focus-visible,
|
||||
.add-outcome:focus-visible {
|
||||
outline: 2px solid var(--shd-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.outcome-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 1.1fr) minmax(220px, 0.8fr);
|
||||
align-items: end;
|
||||
gap: var(--shd-gap-sm);
|
||||
padding: var(--shd-gap-sm);
|
||||
}
|
||||
|
||||
.outcome-grid > :global(.segmented-choice),
|
||||
.assignment-field {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.outcome-grid > :global(.segmented-choice:first-child),
|
||||
.event-note,
|
||||
.outcome-values {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.assignment-field > span {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.event-note,
|
||||
.outcome-empty {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 9px;
|
||||
border: 1px solid var(--shd-border-soft);
|
||||
background: var(--shd-surface-raised);
|
||||
}
|
||||
|
||||
.event-note strong,
|
||||
.outcome-empty strong {
|
||||
color: var(--shd-text);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
letter-spacing: 0.8px;
|
||||
}
|
||||
|
||||
.event-note span,
|
||||
.outcome-empty span {
|
||||
color: var(--shd-text-muted);
|
||||
font-size: var(--shd-type-supporting);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.outcome-values {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--outcome-value-columns), minmax(100px, 1fr));
|
||||
gap: var(--shd-gap-sm);
|
||||
}
|
||||
|
||||
.outcome-values .field {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.outcome-empty {
|
||||
border-left: 2px solid var(--shd-orange);
|
||||
}
|
||||
|
||||
.add-outcome {
|
||||
width: max-content;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.add-outcome:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.outcome-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.outcome-grid > * {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.outcome-values {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.add-outcome {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.remove-outcome {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,735 @@
|
||||
<script lang="ts">
|
||||
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 {
|
||||
blankConditionalMechanicDraft,
|
||||
parseConditionalMechanicDraft,
|
||||
type ConditionalMechanicClauseDraft,
|
||||
type ConditionalMechanicDraftV1,
|
||||
type ConditionalMechanicOutcomeDraft,
|
||||
type ConditionalMechanicPoolDraft
|
||||
} from '$lib/calculations/mechanic-editor';
|
||||
import {
|
||||
formatMechanicProgram,
|
||||
MECHANIC_EVENT_KINDS,
|
||||
MECHANIC_EVENT_REGISTRY,
|
||||
type ClauseMutation,
|
||||
type MechanicOutcomeScope,
|
||||
type NumericExpression,
|
||||
type OutcomeBasis,
|
||||
type OutcomePhase,
|
||||
type Reducer,
|
||||
type ScenarioInputDefinition
|
||||
} from '$lib/calculations/mechanic';
|
||||
import {
|
||||
TRIGGER_ACTION_KEYS,
|
||||
TRIGGER_ACTION_REGISTRY,
|
||||
type TriggerActionKey
|
||||
} from '$lib/calculations/conditional';
|
||||
import { createMechanicRecipe } from '$lib/calculations/mechanic-recipes';
|
||||
|
||||
export type MechanicValueColumn = { key: string; variantKey: string; label: string };
|
||||
type Attribute = { id: string; name: string; unit?: string };
|
||||
|
||||
let {
|
||||
rule = $bindable(blankConditionalMechanicDraft()),
|
||||
attributes,
|
||||
valueColumns,
|
||||
idPrefix = 'mechanic',
|
||||
allowAssignedAbility = false,
|
||||
allowProtocolUpgradeScaling = false,
|
||||
disabled = false
|
||||
}: {
|
||||
rule?: ConditionalMechanicDraftV1;
|
||||
attributes: Attribute[];
|
||||
valueColumns: MechanicValueColumn[];
|
||||
idPrefix?: string;
|
||||
allowAssignedAbility?: boolean;
|
||||
allowProtocolUpgradeScaling?: boolean;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
let recipeKey = $state<TriggerActionKey>('same_target_hits_within_window');
|
||||
let recipeCount = $state(9);
|
||||
let recipeWindow = $state(3);
|
||||
let recipeDistance = $state(10);
|
||||
let replaceConfirmation = $state(false);
|
||||
|
||||
const readinessOptions = [
|
||||
{ value: 'ready', label: 'READY' },
|
||||
{ value: 'requires_assumptions', label: 'ASSUMPTIONS' },
|
||||
{ value: 'captured_only', label: 'CAPTURE ONLY' }
|
||||
];
|
||||
const eventItems = MECHANIC_EVENT_KINDS.map((id) => ({
|
||||
id,
|
||||
name: MECHANIC_EVENT_REGISTRY[id].label
|
||||
}));
|
||||
const reducerItems = [
|
||||
{ id: 'none', name: 'Every matching event' },
|
||||
{ id: 'count', name: 'Count matches' },
|
||||
{ id: 'distinct_count', name: 'Count distinct targets' },
|
||||
{ id: 'consecutive_count', name: 'Count consecutive matches' },
|
||||
{ id: 'sliding_count', name: 'Count within a time window' },
|
||||
{ id: 'duration', name: 'Condition held for a duration' },
|
||||
{ id: 'periodic', name: 'Repeat on an interval' },
|
||||
{ id: 'derived_scalar', name: 'Derive a scalar value' }
|
||||
];
|
||||
const recipeItems = TRIGGER_ACTION_KEYS.map((id) => ({
|
||||
id,
|
||||
name: TRIGGER_ACTION_REGISTRY[id].label
|
||||
}));
|
||||
const outcomeKinds = [
|
||||
{ value: 'attribute_modifier', label: 'ATTRIBUTE' },
|
||||
{ value: 'direct_damage', label: 'DAMAGE' },
|
||||
{ value: 'restoration', label: 'RESTORE' },
|
||||
{ value: 'resource_cost', label: 'COST' },
|
||||
{ value: 'marker', label: 'MARKER' }
|
||||
];
|
||||
const scopeOptions = $derived([
|
||||
{ value: 'self', label: 'SELF' },
|
||||
{ value: 'equipped_weapon', label: 'WEAPON' },
|
||||
{ value: 'target', label: 'TARGET' },
|
||||
{ value: 'allies', label: 'ALLIES' },
|
||||
{ value: 'self_and_allies', label: 'SELF + ALLIES' },
|
||||
...(allowAssignedAbility ? [{ value: 'assigned_ability', label: 'ABILITY' }] : []),
|
||||
{ value: 'all_skills', label: 'ALL SKILLS' }
|
||||
]);
|
||||
|
||||
const slug = (value: string) => value.toLocaleLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'branch';
|
||||
const clone = <T,>(value: T): T => structuredClone(value);
|
||||
const setRule = (next: ConditionalMechanicDraftV1) => { rule = next; };
|
||||
|
||||
function recipeParams() {
|
||||
if (recipeKey === 'same_target_hits_within_window') return { hitsRequired: recipeCount, windowSeconds: recipeWindow };
|
||||
if (recipeKey === 'distinct_enemies_within_window') return { enemiesRequired: recipeCount, windowSeconds: recipeWindow };
|
||||
if (recipeKey === 'consecutive_same_target_hits') return { hitsRequired: recipeCount };
|
||||
if (recipeKey === 'continuous_firing_duration') return { seconds: recipeWindow };
|
||||
if (recipeKey === 'target_hit_within_distance' || recipeKey === 'within_distance') return { meters: recipeDistance };
|
||||
if (recipeKey === 'health_threshold_bands') return { bands: [{ thresholdPercent: 50, outcomeKey: 'active' }] };
|
||||
return {};
|
||||
}
|
||||
|
||||
function applyRecipe(replace = false) {
|
||||
const result = createMechanicRecipe(recipeKey, recipeParams() as never);
|
||||
if (result.status !== 'supported') return;
|
||||
const clauses = result.seed.clauses.map((seed, index): ConditionalMechanicClauseDraft => ({
|
||||
key: `${slug(recipeKey)}-${rule.clauses.length + index + 1}`,
|
||||
label: seed.label ?? TRIGGER_ACTION_REGISTRY[recipeKey].label,
|
||||
event: seed.when,
|
||||
recipeKey,
|
||||
if: seed.if ?? { kind: 'true' },
|
||||
reducer: seed.reducer,
|
||||
cooldownSeconds: seed.cooldownSeconds,
|
||||
weight: seed.weight,
|
||||
mutations: seed.mutations,
|
||||
outcomes: []
|
||||
}));
|
||||
setRule({
|
||||
...rule,
|
||||
scenarioInputs: replace
|
||||
? clone(result.seed.scenarioInputs ?? [])
|
||||
: [...rule.scenarioInputs, ...clone(result.seed.scenarioInputs ?? [])],
|
||||
pools: replace
|
||||
? (result.seed.pools ?? []).map((pool) => ({ ...clone(pool), stateKind: 'stack' as const }))
|
||||
: [
|
||||
...rule.pools,
|
||||
...(result.seed.pools ?? [])
|
||||
.filter((pool) => !rule.pools.some((current) => current.key === pool.key))
|
||||
.map((pool) => ({ ...clone(pool), stateKind: 'stack' as const }))
|
||||
],
|
||||
clauses: replace ? clauses : [...rule.clauses, ...clauses]
|
||||
});
|
||||
replaceConfirmation = false;
|
||||
}
|
||||
|
||||
function blankClause(): ConditionalMechanicClauseDraft {
|
||||
const number = rule.clauses.length + 1;
|
||||
return {
|
||||
key: `branch-${number}`,
|
||||
label: `Branch ${number}`,
|
||||
event: 'weapon_hit',
|
||||
if: { kind: 'true' },
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: []
|
||||
};
|
||||
}
|
||||
|
||||
function blankOutcome(index: number): ConditionalMechanicOutcomeDraft {
|
||||
return {
|
||||
key: `effect-${index + 1}`,
|
||||
label: `Effect ${index + 1}`,
|
||||
phase: 'while_active',
|
||||
kind: 'attribute_modifier',
|
||||
scope: 'self',
|
||||
basis: 'flat',
|
||||
perStack: true,
|
||||
descriptionSlot: index + 1,
|
||||
attributeId: '',
|
||||
variants: Object.fromEntries(valueColumns.map((column) => [
|
||||
column.variantKey,
|
||||
{ value: 0 }
|
||||
]))
|
||||
};
|
||||
}
|
||||
|
||||
function updateClause(index: number, patch: Partial<ConditionalMechanicClauseDraft>) {
|
||||
const clauses = [...rule.clauses];
|
||||
clauses[index] = { ...clauses[index], ...patch };
|
||||
setRule({ ...rule, clauses });
|
||||
}
|
||||
|
||||
function reducerKind(index: number, kind: string) {
|
||||
const reducer: Reducer =
|
||||
kind === 'count' ? { kind: 'count', threshold: 1 }
|
||||
: kind === 'distinct_count' ? { kind: 'distinct_count', threshold: 1, windowSeconds: 1, distinctBy: { source: 'event', path: 'targetId' } }
|
||||
: kind === 'consecutive_count' ? { kind: 'consecutive_count', threshold: 1, groupBy: { source: 'event', path: 'targetId' } }
|
||||
: kind === 'sliding_count' ? { kind: 'sliding_count', threshold: 1, windowSeconds: 1 }
|
||||
: kind === 'duration' ? { kind: 'duration', seconds: 1 }
|
||||
: kind === 'periodic' ? { kind: 'periodic', intervalSeconds: 1 }
|
||||
: kind === 'derived_scalar' ? { kind: 'derived_scalar', value: 1 }
|
||||
: { kind: 'none' };
|
||||
updateClause(index, { reducer });
|
||||
}
|
||||
|
||||
function setReducerNumber(index: number, property: string, raw: string) {
|
||||
const value = Number(raw);
|
||||
const reducer = { ...rule.clauses[index].reducer, [property]: Number.isFinite(value) ? value : 0 } as Reducer;
|
||||
updateClause(index, { reducer });
|
||||
}
|
||||
|
||||
function updateOutcome(clauseIndex: number, outcomeIndex: number, patch: Partial<ConditionalMechanicOutcomeDraft>) {
|
||||
const outcomes = [...rule.clauses[clauseIndex].outcomes];
|
||||
outcomes[outcomeIndex] = { ...outcomes[outcomeIndex], ...patch };
|
||||
updateClause(clauseIndex, { outcomes });
|
||||
}
|
||||
|
||||
function updateVariant(clauseIndex: number, outcomeIndex: number, variant: string, raw: string) {
|
||||
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
||||
const value = Number(raw);
|
||||
const previous = outcome.variants[variant]?.expression;
|
||||
updateOutcome(clauseIndex, outcomeIndex, {
|
||||
variants: {
|
||||
...outcome.variants,
|
||||
[variant]: {
|
||||
...outcome.variants[variant],
|
||||
value,
|
||||
...(previous === undefined ? {} : { expression: replaceExpressionBase(previous, value) })
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type OutcomeValueModel =
|
||||
| 'static'
|
||||
| 'event'
|
||||
| 'reducer'
|
||||
| 'scenario'
|
||||
| 'protocol_upgrades'
|
||||
| 'advanced';
|
||||
|
||||
const valueModelOptions = $derived([
|
||||
{ value: 'static', label: 'STATIC' },
|
||||
{ value: 'event', label: 'EVENT FIELD' },
|
||||
{ value: 'reducer', label: 'REDUCER' },
|
||||
{ value: 'scenario', label: 'SCENARIO' },
|
||||
...(allowProtocolUpgradeScaling
|
||||
? [{ value: 'protocol_upgrades', label: 'PROTOCOL UPGRADES' }]
|
||||
: [])
|
||||
]);
|
||||
const eventValueOptions = [
|
||||
{ value: 'values.enemyCount', label: 'ENEMY COUNT' },
|
||||
{ value: 'values.allyCount', label: 'ALLY COUNT' },
|
||||
{ value: 'values.magazineRemaining', label: 'ROUNDS LEFT' },
|
||||
{ value: 'values.magazinePercent', label: 'MAGAZINE %' },
|
||||
{ value: 'values.distanceMeters', label: 'DISTANCE' }
|
||||
];
|
||||
|
||||
function referenceExpression(source: 'event' | 'scenario', path: string, value: number): NumericExpression {
|
||||
return {
|
||||
op: 'multiply',
|
||||
left: value,
|
||||
right: {
|
||||
ref: source === 'event'
|
||||
? { source, path }
|
||||
: { source, key: path }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function protocolUpgradeExpression(value: number): NumericExpression {
|
||||
return {
|
||||
op: 'multiply',
|
||||
left: value,
|
||||
right: {
|
||||
op: 'add',
|
||||
left: 1,
|
||||
right: {
|
||||
op: 'divide',
|
||||
left: { ref: { source: 'equipment', path: 'protocolTalentUpgrades' } },
|
||||
right: 5
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function outcomeValueModel(outcome: ConditionalMechanicOutcomeDraft): OutcomeValueModel {
|
||||
const expression = Object.values(outcome.variants)[0]?.expression;
|
||||
if (expression === undefined) return 'static';
|
||||
if (
|
||||
typeof expression === 'object'
|
||||
&& 'op' in expression
|
||||
&& expression.op === 'multiply'
|
||||
&& typeof expression.left === 'number'
|
||||
&& typeof expression.right === 'object'
|
||||
) {
|
||||
if ('ref' in expression.right) {
|
||||
if (expression.right.ref.source === 'event') return 'event';
|
||||
if (expression.right.ref.source === 'scenario') return 'scenario';
|
||||
if (expression.right.ref.source === 'reducer') return 'reducer';
|
||||
}
|
||||
if (
|
||||
'op' in expression.right
|
||||
&& expression.right.op === 'add'
|
||||
&& typeof expression.right.right === 'object'
|
||||
&& 'op' in expression.right.right
|
||||
&& expression.right.right.op === 'divide'
|
||||
&& typeof expression.right.right.left === 'object'
|
||||
&& 'ref' in expression.right.right.left
|
||||
&& expression.right.right.left.ref.source === 'equipment'
|
||||
&& expression.right.right.left.ref.path === 'protocolTalentUpgrades'
|
||||
) return 'protocol_upgrades';
|
||||
}
|
||||
return 'advanced';
|
||||
}
|
||||
|
||||
function outcomeReference(outcome: ConditionalMechanicOutcomeDraft, source: 'event' | 'scenario') {
|
||||
const expression = Object.values(outcome.variants)[0]?.expression;
|
||||
if (
|
||||
expression
|
||||
&& typeof expression === 'object'
|
||||
&& 'op' in expression
|
||||
&& expression.op === 'multiply'
|
||||
&& typeof expression.right === 'object'
|
||||
&& 'ref' in expression.right
|
||||
&& expression.right.ref.source === source
|
||||
) {
|
||||
if (expression.right.ref.source === 'event') return expression.right.ref.path;
|
||||
if (expression.right.ref.source === 'scenario') return expression.right.ref.key;
|
||||
}
|
||||
return source === 'event' ? 'values.enemyCount' : rule.scenarioInputs[0]?.key ?? '';
|
||||
}
|
||||
|
||||
function expressionForModel(
|
||||
model: OutcomeValueModel,
|
||||
value: number,
|
||||
reference = ''
|
||||
): NumericExpression | undefined {
|
||||
if (model === 'event') return referenceExpression('event', reference || 'values.enemyCount', value);
|
||||
if (model === 'scenario') return reference
|
||||
? referenceExpression('scenario', reference, value)
|
||||
: undefined;
|
||||
if (model === 'reducer') {
|
||||
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
|
||||
}
|
||||
if (model === 'protocol_upgrades') return protocolUpgradeExpression(value);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function setOutcomeValueModel(
|
||||
clauseIndex: number,
|
||||
outcomeIndex: number,
|
||||
model: OutcomeValueModel,
|
||||
reference = ''
|
||||
) {
|
||||
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
||||
const variants = Object.fromEntries(Object.entries(outcome.variants).map(([key, variant]) => {
|
||||
const expression = expressionForModel(model, Number(variant.value), reference);
|
||||
return [key, expression === undefined ? { value: Number(variant.value) } : {
|
||||
value: Number(variant.value),
|
||||
expression
|
||||
}];
|
||||
}));
|
||||
updateOutcome(clauseIndex, outcomeIndex, { variants });
|
||||
}
|
||||
|
||||
function replaceExpressionBase(expression: NumericExpression, value: number): NumericExpression {
|
||||
const model = outcomeValueModel({
|
||||
key: 'temporary',
|
||||
label: 'Temporary',
|
||||
phase: 'on_event',
|
||||
kind: 'marker',
|
||||
scope: 'self',
|
||||
basis: 'flat',
|
||||
variants: { base: { value, expression } }
|
||||
});
|
||||
if (model === 'event') {
|
||||
return referenceExpression('event', (
|
||||
expression as { right: { ref: { path: string } } }
|
||||
).right.ref.path, value);
|
||||
}
|
||||
if (model === 'scenario') {
|
||||
return referenceExpression('scenario', (
|
||||
expression as { right: { ref: { key: string } } }
|
||||
).right.ref.key, value);
|
||||
}
|
||||
if (model === 'reducer') {
|
||||
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
|
||||
}
|
||||
if (model === 'protocol_upgrades') return protocolUpgradeExpression(value);
|
||||
return expression;
|
||||
}
|
||||
|
||||
function addMutation(clauseIndex: number) {
|
||||
const pool = rule.pools[0]?.key;
|
||||
if (!pool) return;
|
||||
updateClause(clauseIndex, {
|
||||
mutations: [...rule.clauses[clauseIndex].mutations, { kind: 'add', pool, value: 1 }]
|
||||
});
|
||||
}
|
||||
|
||||
function updateMutation(clauseIndex: number, mutationIndex: number, kind: 'add' | 'set' | 'consume' | 'clear', pool?: string, value?: number) {
|
||||
const mutations = [...rule.clauses[clauseIndex].mutations];
|
||||
const current = mutations[mutationIndex];
|
||||
const nextPool = pool ?? ('pool' in current ? current.pool : rule.pools[0]?.key ?? '');
|
||||
mutations[mutationIndex] = kind === 'clear'
|
||||
? { kind, pool: nextPool }
|
||||
: { kind, pool: nextPool, value: value ?? ('value' in current && typeof current.value === 'number' ? current.value : 1) };
|
||||
updateClause(clauseIndex, { mutations });
|
||||
}
|
||||
|
||||
function updatePool(index: number, patch: Partial<ConditionalMechanicPoolDraft>) {
|
||||
const pools = [...rule.pools];
|
||||
pools[index] = { ...pools[index], ...patch };
|
||||
setRule({ ...rule, pools });
|
||||
}
|
||||
|
||||
function updateScenarioInput(index: number, patch: Partial<ScenarioInputDefinition>) {
|
||||
const scenarioInputs = [...rule.scenarioInputs];
|
||||
scenarioInputs[index] = { ...scenarioInputs[index], ...patch };
|
||||
setRule({ ...rule, scenarioInputs });
|
||||
}
|
||||
|
||||
function addScenarioInput() {
|
||||
const number = rule.scenarioInputs.length + 1;
|
||||
setRule({
|
||||
...rule,
|
||||
scenarioInputs: [
|
||||
...rule.scenarioInputs,
|
||||
{
|
||||
key: `assumption-${number}`,
|
||||
label: `Assumption ${number}`,
|
||||
kind: 'slider',
|
||||
readiness: 'requires_assumption',
|
||||
description: '',
|
||||
defaultValue: 0,
|
||||
bestCaseValue: 100,
|
||||
min: 0,
|
||||
max: 100
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
const validation = $derived(parseConditionalMechanicDraft(rule, {
|
||||
variantKeys: valueColumns.map((column) => column.variantKey),
|
||||
allowedAttributeIds: new Set(attributes.map((attribute) => attribute.id))
|
||||
}));
|
||||
const scriptLines = $derived(validation.ok
|
||||
? formatMechanicProgram(validation.value.programs[valueColumns[0]?.variantKey]).split('\n')
|
||||
: ['MECHANIC INCOMPLETE', '', ...validation.errors.map((error) => `ERROR ${error}`)]);
|
||||
const readinessLabel = $derived(
|
||||
rule.simulationReadiness === 'ready'
|
||||
? 'READY'
|
||||
: rule.simulationReadiness === 'requires_assumptions'
|
||||
? 'REQUIRES ASSUMPTIONS'
|
||||
: 'CAPTURED ONLY'
|
||||
);
|
||||
</script>
|
||||
|
||||
<fieldset class="mechanic-editor" {disabled}>
|
||||
<legend>MECHANIC PROGRAM</legend>
|
||||
|
||||
<div class="mechanic-heading">
|
||||
<div>
|
||||
<span>STRUCTURED COMBAT LOGIC</span>
|
||||
<strong>{rule.clauses.length} {rule.clauses.length === 1 ? 'BRANCH' : 'BRANCHES'}</strong>
|
||||
<p>Events are code-defined. Every branch, condition, state change, and outcome belongs to this item.</p>
|
||||
</div>
|
||||
<MechanicLanguageHelp />
|
||||
</div>
|
||||
|
||||
<SegmentedChoice
|
||||
legend="SIMULATION READINESS"
|
||||
name={`${idPrefix}-readiness`}
|
||||
options={readinessOptions}
|
||||
bind:value={rule.simulationReadiness}
|
||||
/>
|
||||
|
||||
<section class="scenario-panel">
|
||||
<header><div><span>BUILDER CONTROLS</span><strong>SCENARIO ASSUMPTIONS</strong></div><button type="button" class="secondary compact-action" onclick={addScenarioInput}>ADD CONTROL +</button></header>
|
||||
<p>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.</p>
|
||||
{#each rule.scenarioInputs as input, index}
|
||||
<div class="scenario-row">
|
||||
<label class="field">LABEL<input value={input.label} oninput={(event) => updateScenarioInput(index,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label>
|
||||
<SegmentedChoice legend="CONTROL TYPE" name={`${idPrefix}-scenario-kind-${index}`} options={[{value:'toggle',label:'TOGGLE'},{value:'slider',label:'SLIDER'},{value:'number',label:'NUMBER'}]} value={input.kind} onchange={(values) => 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})}/>
|
||||
<label class="field description-field">AGENT GUIDANCE<input value={input.description ?? ''} placeholder="Explain what the Agent should estimate." oninput={(event) => updateScenarioInput(index,{description:event.currentTarget.value})}/></label>
|
||||
{#if input.kind === 'toggle'}
|
||||
<SegmentedChoice legend="DEFAULT" name={`${idPrefix}-scenario-default-${index}`} options={[{value:'false',label:'OFF'},{value:'true',label:'ON'}]} value={String(input.defaultValue ?? false)} onchange={(values) => updateScenarioInput(index,{defaultValue:values[0] === 'true'})}/>
|
||||
<SegmentedChoice legend="BEST CASE" name={`${idPrefix}-scenario-best-${index}`} options={[{value:'false',label:'OFF'},{value:'true',label:'ON'}]} value={String(input.bestCaseValue ?? true)} onchange={(values) => updateScenarioInput(index,{bestCaseValue:values[0] === 'true'})}/>
|
||||
{:else}
|
||||
<label class="field">MIN<input type="number" step="any" value={input.min ?? 0} oninput={(event) => updateScenarioInput(index,{min:Number(event.currentTarget.value)})}/></label>
|
||||
<label class="field">MAX<input type="number" step="any" value={input.max ?? 100} oninput={(event) => updateScenarioInput(index,{max:Number(event.currentTarget.value)})}/></label>
|
||||
<label class="field">DEFAULT<input type="number" step="any" value={Number(input.defaultValue ?? 0)} oninput={(event) => updateScenarioInput(index,{defaultValue:Number(event.currentTarget.value)})}/></label>
|
||||
<label class="field">BEST CASE<input type="number" step="any" value={Number(input.bestCaseValue ?? input.max ?? 100)} oninput={(event) => updateScenarioInput(index,{bestCaseValue:Number(event.currentTarget.value)})}/></label>
|
||||
{/if}
|
||||
<button type="button" class="remove" onclick={() => setRule({...rule,scenarioInputs:rule.scenarioInputs.filter((_,current) => current !== index)})}>REMOVE</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty-state">No source-specific assumptions. This mechanic uses only the shared encounter timeline.</p>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section class="recipe-panel">
|
||||
<header><div><span>RECIPE STARTER</span><strong>SEED AN EDITABLE BRANCH</strong></div><small>Recipes copy typed data into this item; they are not shared runtime dependencies.</small></header>
|
||||
<div class="recipe-grid">
|
||||
<SearchableAssignment
|
||||
items={recipeItems}
|
||||
mode="single"
|
||||
compact
|
||||
subjectLabel="RECIPE"
|
||||
bind:value={recipeKey}
|
||||
/>
|
||||
{#if ['same_target_hits_within_window', 'distinct_enemies_within_window', 'consecutive_same_target_hits'].includes(recipeKey)}
|
||||
<label class="field">COUNT<input type="number" min="1" step="1" bind:value={recipeCount}/></label>
|
||||
{/if}
|
||||
{#if ['same_target_hits_within_window', 'distinct_enemies_within_window', 'continuous_firing_duration'].includes(recipeKey)}
|
||||
<label class="field">SECONDS<input type="number" min="0.01" step="any" bind:value={recipeWindow}/></label>
|
||||
{/if}
|
||||
{#if ['target_hit_within_distance', 'within_distance'].includes(recipeKey)}
|
||||
<label class="field">METERS<input type="number" min="0" step="any" bind:value={recipeDistance}/></label>
|
||||
{/if}
|
||||
<button type="button" class="secondary" onclick={() => applyRecipe(false)}>ADD RECIPE BRANCH +</button>
|
||||
{#if rule.clauses.length}
|
||||
<button type="button" class="danger-quiet" onclick={() => replaceConfirmation = true}>REPLACE PROGRAM</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if replaceConfirmation}
|
||||
<div class="replace-confirm" role="alert">
|
||||
<p><strong>REPLACE ALL CURRENT BRANCHES?</strong><span>This keeps no existing program data.</span></p>
|
||||
<div><button type="button" class="secondary" onclick={() => replaceConfirmation = false}>CANCEL</button><button type="button" class="primary" onclick={() => applyRecipe(true)}>CONFIRM REPLACE</button></div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="state-panel">
|
||||
<header><div><span>STATE</span><strong>POOLS</strong></div><button type="button" class="secondary compact-action" onclick={() => setRule({...rule,pools:[...rule.pools,{key:`state-${rule.pools.length + 1}`,label:`State ${rule.pools.length + 1}`,stateKind:'stack',scope:'agent',initial:0,min:0,max:1,bestCaseValue:1}]})}>ADD POOL +</button></header>
|
||||
{#each rule.pools as pool, index}
|
||||
<div class="pool-row">
|
||||
<label class="field">LABEL<input value={pool.label} oninput={(event) => updatePool(index,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label>
|
||||
<SegmentedChoice legend="SCOPE" name={`${idPrefix}-pool-scope-${index}`} options={[{value:'agent',label:'AGENT'},{value:'target',label:'TARGET'}]} value={pool.scope} onchange={(values) => updatePool(index,{scope:values[0] as 'agent'|'target'})}/>
|
||||
<label class="field">INITIAL<input type="number" value={pool.initial} oninput={(event) => updatePool(index,{initial:Number(event.currentTarget.value)})}/></label>
|
||||
<label class="field">MAX<input type="number" min="1" value={pool.max ?? 1} oninput={(event) => updatePool(index,{max:Number(event.currentTarget.value)})}/></label>
|
||||
<label class="field">BEST CASE<input type="number" value={pool.bestCaseValue ?? pool.max ?? 1} oninput={(event) => updatePool(index,{bestCaseValue:Number(event.currentTarget.value)})}/></label>
|
||||
<label class="field">EXPIRY · SECONDS<input type="number" min="0.01" step="any" value={pool.expiresAfterSeconds ?? ''} oninput={(event) => updatePool(index,{expiresAfterSeconds:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
|
||||
<button type="button" class="remove" onclick={() => setRule({...rule,pools:rule.pools.filter((_,current) => current !== index)})}>REMOVE</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty-state">No state pools. Add one for stacks, timers, counters, resources, or target marks.</p>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<div class="branch-stack">
|
||||
{#each rule.clauses as clause, clauseIndex}
|
||||
<section class="branch">
|
||||
<header>
|
||||
<div><span>{String(clauseIndex + 1).padStart(2, '0')}</span><label class="field">BRANCH LABEL<input value={clause.label ?? ''} oninput={(event) => updateClause(clauseIndex,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label></div>
|
||||
<button type="button" class="remove" onclick={() => setRule({...rule,clauses:rule.clauses.filter((_,index) => index !== clauseIndex)})}>REMOVE BRANCH</button>
|
||||
</header>
|
||||
<div class="branch-grid">
|
||||
<div class="field"><span>DIRECT EVENT</span><SearchableAssignment items={eventItems} mode="single" compact subjectLabel="EVENT" bind:value={clause.event}/></div>
|
||||
<div class="branch-readout"><span>MATCH POLICY</span><strong>{reducerItems.find((item) => item.id === clause.reducer.kind)?.name ?? clause.reducer.kind}</strong></div>
|
||||
<label class="field">COOLDOWN · SECONDS<input type="number" min="0.01" step="any" value={clause.cooldownSeconds ?? ''} oninput={(event) => updateClause(clauseIndex,{cooldownSeconds:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
|
||||
</div>
|
||||
<div class="reducer-controls">
|
||||
<SegmentedChoice
|
||||
legend="MATCH POLICY"
|
||||
name={`${idPrefix}-reducer-${clauseIndex}`}
|
||||
options={reducerItems.map((item) => ({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)}
|
||||
<label class="field">THRESHOLD<input type="number" min="1" step="1" value={'threshold' in clause.reducer ? clause.reducer.threshold : 1} oninput={(event) => setReducerNumber(clauseIndex,'threshold',event.currentTarget.value)}/></label>
|
||||
{/if}
|
||||
{#if ['distinct_count','sliding_count'].includes(clause.reducer.kind)}
|
||||
<label class="field">WINDOW · SECONDS<input type="number" min="0.01" step="any" value={'windowSeconds' in clause.reducer ? clause.reducer.windowSeconds : 1} oninput={(event) => setReducerNumber(clauseIndex,'windowSeconds',event.currentTarget.value)}/></label>
|
||||
{/if}
|
||||
{#if clause.reducer.kind === 'duration'}
|
||||
<label class="field">DURATION · SECONDS<input type="number" min="0.01" step="any" value={clause.reducer.seconds} oninput={(event) => setReducerNumber(clauseIndex,'seconds',event.currentTarget.value)}/></label>
|
||||
{:else if clause.reducer.kind === 'periodic'}
|
||||
<label class="field">INTERVAL · SECONDS<input type="number" min="0.01" step="any" value={typeof clause.reducer.intervalSeconds === 'number' ? clause.reducer.intervalSeconds : 1} oninput={(event) => setReducerNumber(clauseIndex,'intervalSeconds',event.currentTarget.value)}/></label>
|
||||
{:else if clause.reducer.kind === 'derived_scalar'}
|
||||
<label class="field">DERIVED VALUE<input type="number" step="any" value={typeof clause.reducer.value === 'number' ? clause.reducer.value : 1} oninput={(event) => setReducerNumber(clauseIndex,'value',event.currentTarget.value)}/></label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mutation-panel">
|
||||
<div class="outcome-heading"><div><span>STATE CHANGES</span><strong>{clause.mutations.length}</strong></div><button type="button" class="secondary compact-action" onclick={() => addMutation(clauseIndex)} disabled={!rule.pools.length}>ADD STATE CHANGE +</button></div>
|
||||
{#each clause.mutations as mutation, mutationIndex}
|
||||
<div class="mutation-row">
|
||||
<SegmentedChoice
|
||||
legend="OPERATION"
|
||||
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
|
||||
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'clear',label:'CLEAR'}]}
|
||||
value={mutation.kind === 'none' || mutation.kind === 'derive' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
|
||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'clear')}
|
||||
/>
|
||||
<SegmentedChoice
|
||||
legend="STATE POOL"
|
||||
name={`${idPrefix}-mutation-pool-${clauseIndex}-${mutationIndex}`}
|
||||
options={rule.pools.map((pool) => ({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'}
|
||||
<label class="field">VALUE<input type="number" step="any" value={'value' in mutation && typeof mutation.value === 'number' ? mutation.value : 1} oninput={(event) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',undefined,Number(event.currentTarget.value))}/></label>
|
||||
{/if}
|
||||
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty-state">This branch emits outcomes without changing shared state.</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="outcome-heading"><div><span>OUTCOMES</span><strong>{clause.outcomes.length}</strong></div><button type="button" class="secondary compact-action" onclick={() => updateClause(clauseIndex,{outcomes:[...clause.outcomes,blankOutcome(clause.outcomes.length)]})}>ADD OUTCOME +</button></div>
|
||||
{#each clause.outcomes as outcome, outcomeIndex}
|
||||
<div class="outcome-card">
|
||||
<div class="outcome-grid">
|
||||
<label class="field">LABEL<input value={outcome.label} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label>
|
||||
<SegmentedChoice legend="KIND" name={`${idPrefix}-kind-${clauseIndex}-${outcomeIndex}`} options={outcomeKinds} value={outcome.kind} onchange={(values) => 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})}/>
|
||||
<label class="field">DESCRIPTION SLOT<input type="number" min="1" step="1" value={outcome.descriptionSlot ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{descriptionSlot:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
|
||||
</div>
|
||||
<SegmentedChoice legend="TARGET SCOPE" name={`${idPrefix}-scope-${clauseIndex}-${outcomeIndex}`} options={scopeOptions} value={outcome.scope} columns={scopeOptions.length > 4 ? 4 : scopeOptions.length} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{scope:values[0] as MechanicOutcomeScope})}/>
|
||||
{#if outcome.kind === 'attribute_modifier'}
|
||||
<SearchableAssignment items={attributes} mode="single" compact subjectLabel="ATTRIBUTE" bind:value={outcome.attributeId}/>
|
||||
{:else if outcome.kind === 'restoration' || outcome.kind === 'resource_cost'}
|
||||
<SegmentedChoice legend="RESOURCE" name={`${idPrefix}-resource-${clauseIndex}-${outcomeIndex}`} options={[{value:'health',label:'HEALTH'},{value:'armor',label:'ARMOR'},{value:'ammo',label:'AMMO'}]} value={outcome.resource ?? 'health'} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{resource:values[0] as 'health'|'armor'|'ammo'})}/>
|
||||
{/if}
|
||||
{#if outcome.kind === 'direct_damage' || outcome.kind === 'restoration' || outcome.kind === 'resource_cost'}
|
||||
<SegmentedChoice legend="VALUE BASIS" name={`${idPrefix}-basis-${clauseIndex}-${outcomeIndex}`} options={[{value:'flat',label:'FLAT'},{value:'max_health_percent',label:'MAX HEALTH %'},{value:'current_health_percent',label:'CURRENT HEALTH %'},{value:'weapon_base_percent',label:'WEAPON BASE %'}]} value={outcome.basis} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{basis:values[0] as OutcomeBasis})}/>
|
||||
{/if}
|
||||
<SegmentedChoice
|
||||
legend="VALUE MODEL"
|
||||
name={`${idPrefix}-value-model-${clauseIndex}-${outcomeIndex}`}
|
||||
options={outcomeValueModel(outcome) === 'advanced' ? [...valueModelOptions,{value:'advanced',label:'ADVANCED',disabled:true}] : valueModelOptions}
|
||||
value={outcomeValueModel(outcome)}
|
||||
columns={3}
|
||||
onchange={(values) => setOutcomeValueModel(clauseIndex,outcomeIndex,values[0] as OutcomeValueModel,values[0] === 'event' ? 'values.enemyCount' : values[0] === 'scenario' ? rule.scenarioInputs[0]?.key ?? '' : '')}
|
||||
/>
|
||||
{#if outcomeValueModel(outcome) === 'event'}
|
||||
<SegmentedChoice
|
||||
legend="EVENT FIELD"
|
||||
name={`${idPrefix}-event-value-${clauseIndex}-${outcomeIndex}`}
|
||||
options={eventValueOptions}
|
||||
value={outcomeReference(outcome,'event')}
|
||||
columns={3}
|
||||
onchange={(values) => setOutcomeValueModel(clauseIndex,outcomeIndex,'event',values[0])}
|
||||
/>
|
||||
{:else if outcomeValueModel(outcome) === 'scenario' && rule.scenarioInputs.length}
|
||||
<SegmentedChoice
|
||||
legend="SCENARIO CONTROL"
|
||||
name={`${idPrefix}-scenario-value-${clauseIndex}-${outcomeIndex}`}
|
||||
options={rule.scenarioInputs.map((input) => ({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}
|
||||
<div class="state-scaling">
|
||||
<SegmentedChoice
|
||||
legend="STATE POOL"
|
||||
name={`${idPrefix}-outcome-pool-${clauseIndex}-${outcomeIndex}`}
|
||||
options={[{value:'',label:'NONE'},...rule.pools.map((pool) => ({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}
|
||||
<SegmentedChoice legend="SCALING" name={`${idPrefix}-outcome-scaling-${clauseIndex}-${outcomeIndex}`} options={[{value:'false',label:'FLAT'},{value:'true',label:'PER STACK'}]} value={String(outcome.perStack ?? true)} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{perStack:values[0] === 'true'})}/>
|
||||
<label class="field">MIN STACK<input type="number" min="0" step="1" value={outcome.minStacks ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{minStacks:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
|
||||
<label class="field">MAX STACK<input type="number" min="0" step="1" value={outcome.maxStacks ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{maxStacks:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="variant-grid">
|
||||
{#each valueColumns as column}
|
||||
<label class="field">{column.label} VALUE<input type="number" step="any" value={outcome.variants[column.variantKey]?.value ?? 0} oninput={(event) => updateVariant(clauseIndex,outcomeIndex,column.variantKey,event.currentTarget.value)}/></label>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="outcome-footer">
|
||||
<SegmentedChoice legend="PHASE" name={`${idPrefix}-phase-${clauseIndex}-${outcomeIndex}`} options={[{value:'on_event',label:'ON EVENT'},{value:'after_event',label:'AFTER EVENT'},{value:'while_active',label:'WHILE ACTIVE'},{value:'on_enter',label:'ON ENTER'},{value:'on_exit',label:'ON EXIT'}]} value={outcome.phase} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{phase:values[0] as OutcomePhase})}/>
|
||||
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{outcomes:clause.outcomes.filter((_,index) => index !== outcomeIndex)})}>REMOVE OUTCOME</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty-state">This branch has no outcomes yet.</p>
|
||||
{/each}
|
||||
</section>
|
||||
{/each}
|
||||
<button type="button" class="secondary add-branch" onclick={() => setRule({...rule,clauses:[...rule.clauses,blankClause()]})}>ADD DIRECT EVENT BRANCH +</button>
|
||||
</div>
|
||||
|
||||
<section class="script-preview">
|
||||
<div><span>GENERATED MECHANIC</span><strong>READ-ONLY PSEUDO-SCRIPT</strong><p>The server validates the same typed structure for every configured value variant.</p></div>
|
||||
<PseudoScript title="CURRENT MECHANIC PROGRAM" lines={scriptLines} readiness={readinessLabel}/>
|
||||
</section>
|
||||
</fieldset>
|
||||
|
||||
<style>
|
||||
.mechanic-editor{display:grid;gap:var(--shd-gap-sm);margin:0;padding:var(--shd-gap-sm);border:1px solid var(--shd-model-border);border-left:2px solid var(--shd-orange);background:var(--shd-model-surface)}
|
||||
.mechanic-editor>legend{padding:0 7px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.2px}
|
||||
.mechanic-heading,.recipe-panel>header,.scenario-panel>header,.state-panel>header,.branch>header,.outcome-heading{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm)}
|
||||
.mechanic-heading{padding:8px 10px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}
|
||||
.mechanic-heading>div,.recipe-panel>header>div,.scenario-panel>header>div,.state-panel>header>div,.outcome-heading>div{display:grid;gap:2px}
|
||||
.mechanic-heading span,.recipe-panel header span,.scenario-panel header span,.state-panel header span,.outcome-heading span{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.9px}
|
||||
.mechanic-heading p,.recipe-panel header small,.empty-state,.script-preview>div p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
|
||||
.recipe-panel,.scenario-panel,.state-panel,.branch{border:1px solid var(--shd-border);background:var(--shd-surface)}
|
||||
.recipe-panel>header,.scenario-panel>header,.state-panel>header,.branch>header,.outcome-heading{min-height:42px;padding:7px 9px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}
|
||||
.scenario-panel>p{margin:0;padding:8px 9px;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4;border-bottom:1px solid var(--shd-border-soft)}
|
||||
.scenario-row{display:grid;grid-template-columns:minmax(170px,1fr) minmax(260px,1.25fr) minmax(220px,1.3fr) repeat(4,minmax(85px,.45fr)) auto;align-items:end;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border-soft)}
|
||||
.scenario-row>.field{margin:0}.scenario-row>.description-field{min-width:0}
|
||||
.recipe-grid{display:grid;grid-template-columns:minmax(260px,1fr) repeat(3,minmax(110px,.35fr)) auto auto;align-items:end;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)}
|
||||
.recipe-grid>.field,.pool-row>.field,.branch-grid>.field,.outcome-grid>.field,.variant-grid>.field{margin:0}
|
||||
.replace-confirm{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm);padding:9px;border-top:1px solid var(--shd-danger);background:color-mix(in srgb,var(--shd-danger) 10%,var(--shd-surface))}
|
||||
.replace-confirm p{display:grid;gap:2px;margin:0}.replace-confirm span{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.replace-confirm>div{display:flex;gap:7px}
|
||||
.pool-row{display:grid;grid-template-columns:1.2fr .8fr repeat(4,.55fr) auto;align-items:end;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border-soft)}
|
||||
.branch-stack{display:grid;gap:var(--shd-gap-sm)}
|
||||
.branch>header>div{display:flex;align-items:end;gap:9px}.branch>header>div>span{align-self:center;color:var(--shd-orange);font:900 var(--shd-type-telemetry)/1 var(--shd-font-mono)}.branch>header .field{min-width:min(360px,55vw);margin:0}
|
||||
.branch-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr)) minmax(160px,.45fr);align-items:end;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)}
|
||||
.field>span{display:block;margin-bottom:5px;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
|
||||
.branch-readout{display:grid;align-content:center;gap:3px;min-height:44px;padding:7px 9px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}.branch-readout span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.branch-readout strong{font-size:var(--shd-type-supporting)}
|
||||
.reducer-controls{display:grid;grid-template-columns:minmax(0,1fr) repeat(2,minmax(130px,.25fr));align-items:end;gap:var(--shd-gap-sm);padding:0 var(--shd-gap-sm) var(--shd-gap-sm)}
|
||||
.mutation-panel{margin:0 var(--shd-gap-sm) var(--shd-gap-sm);border:1px solid var(--shd-border-soft)}
|
||||
.mutation-row{display:grid;grid-template-columns:minmax(300px,1fr) minmax(260px,1fr) minmax(110px,.35fr) auto;align-items:end;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border-soft)}
|
||||
.outcome-card{display:grid;gap:8px;margin:var(--shd-gap-sm);padding:9px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}
|
||||
.outcome-grid{display:grid;grid-template-columns:minmax(200px,1fr) minmax(360px,1.4fr) minmax(120px,.3fr);align-items:end;gap:8px}
|
||||
.variant-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px}
|
||||
.state-scaling{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr) repeat(2,minmax(110px,.3fr));align-items:end;gap:8px}
|
||||
.outcome-footer{display:flex;align-items:end;justify-content:space-between;gap:8px}
|
||||
.remove,.danger-quiet{min-height:38px;padding:0 10px;border:1px solid var(--shd-danger);background:transparent;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}
|
||||
.remove:hover,.remove:focus-visible,.danger-quiet:hover,.danger-quiet:focus-visible{background:var(--shd-danger);color:var(--shd-surface)}
|
||||
.compact-action{min-height:36px}.add-branch{min-height:44px}.empty-state{padding:10px}.script-preview{display:grid;grid-template-columns:minmax(190px,.28fr) minmax(0,1fr);gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-border);border-top:2px solid var(--shd-success);background:var(--shd-surface)}
|
||||
.script-preview>div{display:grid;align-content:start;gap:3px}.script-preview>div span{color:var(--shd-success);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
|
||||
@media(max-width:1300px){.scenario-row{grid-template-columns:repeat(2,minmax(0,1fr))}.scenario-row>.description-field{grid-column:1/-1}}
|
||||
@media(max-width:1000px){.recipe-grid,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.script-preview{grid-template-columns:1fr 1fr}.recipe-grid>:first-child,.script-preview>:last-child{grid-column:1/-1}.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:1/-1}}
|
||||
@media(max-width:620px){.mechanic-heading,.recipe-panel>header,.scenario-panel>header,.state-panel>header,.branch>header,.outcome-heading,.replace-confirm,.outcome-footer{align-items:stretch;flex-direction:column}.recipe-grid,.scenario-row,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.script-preview{grid-template-columns:1fr}.recipe-grid>:first-child,.script-preview>:last-child,.scenario-row>.description-field,.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:auto}.branch>header>div{align-items:stretch;flex-direction:column}.branch>header .field{min-width:0}.remove,.danger-quiet,.compact-action,.add-branch{width:100%;min-height:44px}}
|
||||
@media(prefers-reduced-motion:reduce){.remove,.danger-quiet{transition:none}}
|
||||
</style>
|
||||
@@ -30,7 +30,6 @@ export async function auditRecord(table: string, id: string): Promise<Record<str
|
||||
if (![
|
||||
'stat_attributes',
|
||||
'talents',
|
||||
'talent_triggers',
|
||||
'brand_sets',
|
||||
'gear',
|
||||
'weapons',
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { conditionalRuleDraftFromRows } from './conditional-effects.js';
|
||||
|
||||
const rule = {
|
||||
id: 'rule-1',
|
||||
simulation_readiness: 'simulation_ready',
|
||||
scenario_inputs_json: '[]'
|
||||
};
|
||||
|
||||
const clause = {
|
||||
id: 'clause-1',
|
||||
rule_id: 'rule-1',
|
||||
clause_key: 'hit',
|
||||
label: 'Hit',
|
||||
event_kind: 'weapon_hit',
|
||||
conditions_json: '{"kind":"true"}',
|
||||
reducer_json: '{"kind":"none"}',
|
||||
mutations_json: '[]',
|
||||
sort_order: 1,
|
||||
active: 1
|
||||
};
|
||||
|
||||
const outcome = {
|
||||
rule_id: 'rule-1',
|
||||
clause_id: 'clause-1',
|
||||
effect_key: 'damage',
|
||||
outcome_key: 'Damage',
|
||||
application_kind: 'active_modifier',
|
||||
effect_scope: 'self',
|
||||
stat_attribute_id: 'weapon-damage',
|
||||
value_basis: 'flat',
|
||||
value_scaling: 'flat',
|
||||
damage_basis: null,
|
||||
application_phase: 'while_active',
|
||||
combat_timing: 'current',
|
||||
resource_kind: null,
|
||||
state_pool_id: null,
|
||||
min_state_value: null,
|
||||
max_state_value: null,
|
||||
description_slot: 1,
|
||||
value: 3,
|
||||
value_expression_json: null
|
||||
};
|
||||
|
||||
describe('conditional mechanic persistence adapter', () => {
|
||||
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 }
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
type PoolRow = Record<string, unknown>;
|
||||
type ClauseRow = Record<string, unknown>;
|
||||
type OutcomeRow = Record<string, unknown>;
|
||||
|
||||
const parseJson = <T>(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<string>;
|
||||
allowedRecipeKeys?: ReadonlySet<string>;
|
||||
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<string, OutcomeRow[]>();
|
||||
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<string, OutcomeRow[]>();
|
||||
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<NumericExpression>(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<NumericExpression>(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<string, string>();
|
||||
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)
|
||||
};
|
||||
}
|
||||
@@ -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<Record<DataModule, Record<string, { saved: string; mode?: string }>>> = {
|
||||
@@ -24,10 +24,7 @@ const proposalResultKeys: Partial<Record<DataModule, Record<string, { saved: str
|
||||
weapons: { save: { saved: 'weaponSaved', mode: 'weaponMode' } },
|
||||
weaponmods: { save: { saved: 'weaponModSaved', mode: 'weaponModMode' } },
|
||||
gearmods: { save: { saved: 'gearModSaved', mode: 'gearModMode' } },
|
||||
talents: {
|
||||
saveTrigger: { saved: 'triggerSaved', mode: 'triggerMode' },
|
||||
saveTalent: { saved: 'talentSaved', mode: 'talentMode' }
|
||||
},
|
||||
talents: { saveTalent: { saved: 'talentSaved', mode: 'talentMode' } },
|
||||
protocols: {
|
||||
save: { saved: 'protocolSaved', mode: 'protocolMode' },
|
||||
saveProfile: { saved: 'profileSaved' }
|
||||
|
||||
@@ -13,6 +13,10 @@ export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
talentTiers,
|
||||
talentEffects,
|
||||
talentTemplates,
|
||||
conditionalRules,
|
||||
conditionalPools,
|
||||
conditionalClauses,
|
||||
conditionalOutcomes,
|
||||
chipSets,
|
||||
chipTwoPiece,
|
||||
chipTalents,
|
||||
@@ -26,6 +30,7 @@ export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
specializations,
|
||||
specializationFocuses,
|
||||
specializationAbilities,
|
||||
specializationAbilityAttacks,
|
||||
focusAbilities,
|
||||
specializationNodes,
|
||||
specializationRanks,
|
||||
@@ -35,7 +40,8 @@ export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
specializationCombatModifiers,
|
||||
activeCalculationRevision,
|
||||
calculationStages,
|
||||
calculationProfiles
|
||||
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(),
|
||||
@@ -48,6 +54,10 @@ export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
(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_talents')).all(),
|
||||
@@ -61,6 +71,7 @@ export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
(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(),
|
||||
@@ -73,10 +84,23 @@ export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
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()
|
||||
]);
|
||||
|
||||
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,
|
||||
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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,7 +89,6 @@ export function actionFeedback(
|
||||
const actionMessages: Record<string, string> = {
|
||||
save: 'Changes saved',
|
||||
saveTalent: 'Talent saved',
|
||||
saveTrigger: 'Trigger saved',
|
||||
savePreferences: 'Preferences saved',
|
||||
saveCoreRanges: 'Configuration saved',
|
||||
saveProfile: 'Profile saved',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
+175
-28
@@ -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<string, any>;
|
||||
if (typeof value !== 'string') return {};
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? parsed as Record<string, any>
|
||||
: {};
|
||||
} 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<string, unknown>) => {
|
||||
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'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="builder-status"><span><i></i> LIVE CALCULATION</span><span>18 EQUIPMENT POSITIONS</span><span>{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS</span></div>
|
||||
<div class="builder-status"><span><i></i> LIVE CALCULATION</span><span>{draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : 'CUSTOM SCENARIO'}</span><span>18 EQUIPMENT POSITIONS</span><span>{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS</span></div>
|
||||
</div>
|
||||
{#if message}<p class="success" role="status">{message}</p>{/if}
|
||||
{#if form?.buildError}<p class="error" role="alert">{form.buildError}</p>{/if}
|
||||
@@ -394,6 +495,20 @@
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{#if draft.calculationMode === 'custom'}<div class="builder-skill-activation">
|
||||
<div class="builder-section-title"><div><span>SKILL ACTIVATION PROFILE</span><small>ALL ENABLED ABILITIES CONTRIBUTE TO COMBINED SKILL DPS</small></div><b>{selectedFocus.abilities.filter((ability: any) => (draft.skillActivations[ability.id]?.mode ?? 'off') !== 'off').length} ENABLED</b></div>
|
||||
{#each selectedFocus.abilities as ability}
|
||||
{@const activation = draft.skillActivations[ability.id] ?? { mode: 'off', targets: 1 }}
|
||||
<section>
|
||||
<header><ItemIcon src={ability.iconUrl} alt=""/><div><strong>{ability.name}</strong><small>{ability.attack ? `${ability.attack.delivery.toUpperCase()} · ${ability.attack.damage_type}` : 'ATTACK PROFILE NOT CONFIGURED'}</small></div></header>
|
||||
<SegmentedChoice legend={`${ability.name} activation`} name={`skill-${ability.id}-activation`} options={[{value:'off',label:'OFF'},{value:'on_cooldown',label:'ON COOLDOWN'},{value:'custom_per_minute',label:'CUSTOM / MIN'}]} value={activation.mode} onchange={(values) => setSkillActivation(ability.id,{mode:values[0]})}/>
|
||||
<div class="builder-skill-assumptions">
|
||||
{#if activation.mode === 'custom_per_minute'}<RangeValueControl label="Uses per minute" min={0.1} max={60} step={0.1} value={activation.usesPerMinute ?? 1} onchange={(value) => setSkillActivation(ability.id,{usesPerMinute:value})}/>{/if}
|
||||
<RangeValueControl label="Targets hit" min={1} max={Math.max(1,Number(ability.attack?.target_cap ?? 1))} step={1} value={activation.targets} onchange={(value) => setSkillActivation(ability.id,{targets:value})}/>
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</div>{/if}
|
||||
<div class="builder-specialization-telemetry"><span>POINTS <b>{selectedSpecializationPoints} / {data.specializationProgression.availablePoints}</b></span><span>CREDITS <b>{selectedSpecializationCosts.credits.toLocaleString()}</b></span>{#each selectedSpecializationCosts.resources as resource}<span>{String(resource[0]).replace('resource-electronics-','').replaceAll('-',' ').toUpperCase()} <b>× {resource[1]}</b></span>{/each}</div>
|
||||
<div class="builder-focus-tree">
|
||||
<div class="builder-section-title"><div><span>FOCUS UPGRADE TREE</span><small>PREVIOUS ROWS REQUIRE EVERY AVAILABLE RANK 1 + 2 BEFORE ADVANCING</small></div><b>{selectedFocus.nodes.length} NODES</b></div>
|
||||
@@ -405,9 +520,40 @@
|
||||
</div></section>
|
||||
{/each}
|
||||
</div>
|
||||
<CombatAssumptions bind:profile={draft.specialization.combatProfile}/>
|
||||
<CombatAssumptions bind:mode={draft.calculationMode} bind:profile={draft.combatProfile}/>
|
||||
{#if selectedCombatEffects.length}
|
||||
<div class="builder-conditional-effects"><div class="builder-section-title"><div><span>CONDITIONAL EFFECT PROFILES</span><small>CHOOSE HOW EACH UNLOCKED EFFECT CONTRIBUTES TO DPS</small></div><b>{selectedCombatEffects.length} EFFECTS</b></div>{#each selectedCombatEffects as effect}<ConditionalEffectCard id={effect.id} name={effect.definition?.name ?? effect.nodeName} description={combatEffectDescription(effect)} facts={[`${effect.trigger_count} HITS WITHIN ${effect.trigger_window_seconds}s`, `+${effect.stacks_per_trigger} STACK · MAX ${effect.max_stacks}`, `EXPIRES ${effect.duration_seconds}s AFTER LAST GAIN`, ...effect.modifiers.map((modifier: any) => `${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}</div>
|
||||
<div class="builder-conditional-effects">
|
||||
<div class="builder-section-title"><div><span>CONDITIONAL EFFECT PROFILES</span><small>CHOOSE HOW EACH UNLOCKED EFFECT CONTRIBUTES TO DPS</small></div><b>{selectedCombatEffects.length} EFFECTS</b></div>
|
||||
{#each selectedCombatEffects as effect}
|
||||
<ConditionalEffectCard
|
||||
id={effect.id}
|
||||
name={effect.definition?.name ?? effect.nodeName}
|
||||
description={combatEffectDescription(effect)}
|
||||
facts={conditionalFacts(effect.rule)}
|
||||
scenarioInputs={mechanicScenarioInputs(effect.rule)}
|
||||
appliedState={draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : draft.conditionalEffects[effect.id]?.mode === 'peak' ? 'PEAK MODE' : draft.conditionalEffects[effect.id]?.mode === 'encounter' ? 'ENCOUNTER MODE' : 'NOT APPLIED'}
|
||||
bind:effectState={draft.conditionalEffects[effect.id]}
|
||||
calculationMode={draft.calculationMode}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if modeledTalents.length}
|
||||
<div class="builder-conditional-effects">
|
||||
<div class="builder-section-title"><div><span>EQUIPPED TALENT MODELING</span><small>MODEL EACH MECHANIC PROGRAM OR EXCLUDE IT FROM CALCULATIONS</small></div><b>{modeledTalents.length} TALENTS</b></div>
|
||||
{#each modeledTalents as equipped}
|
||||
<ConditionalEffectCard
|
||||
id={equipped.key}
|
||||
name={equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}
|
||||
description={equipped.description}
|
||||
facts={conditionalFacts(equipped.rule)}
|
||||
scenarioInputs={mechanicScenarioInputs(equipped.rule)}
|
||||
appliedState={draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : draft.conditionalEffects[equipped.key]?.mode === 'peak' ? 'PEAK MODE' : draft.conditionalEffects[equipped.key]?.mode === 'encounter' ? 'ENCOUNTER MODE' : 'NOT APPLIED'}
|
||||
bind:effectState={draft.conditionalEffects[equipped.key]}
|
||||
calculationMode={draft.calculationMode}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else if activeParts[0] === 'gear'}
|
||||
@@ -478,12 +624,13 @@
|
||||
<div class="summary-head"><div><p class="eyebrow"><span></span> LIVE SUMMARY</p><h2>AGENT STATS</h2></div><b>{summary.stats.length}</b></div>
|
||||
<div class="summary-core">{#each ['Firepower','Toughness','Engineering'] as name}{@const stat = summary.stats.find((entry) => attribute(entry.id)?.name === name)}<div><GameIcon name={name.toLowerCase() as GameIconName} decorative/><span>{name}<b>{stat ? unitValue(stat.value,stat.id) : '0'}</b></span></div>{/each}</div>
|
||||
<div class="builder-combat-results">{#each combatResults as result}<WeaponCalculationResult {result}/>{/each}</div>
|
||||
<div class="builder-combat-results"><SkillCalculationResult result={skillCombatResult} abilityNames={skillAbilityNames}/></div>
|
||||
<div class="summary-scroll">
|
||||
<div class="summary-section"><header><span>COMBINED ATTRIBUTES</span><b>{summary.stats.length}</b></header>{#each summary.stats as stat}<div class="summary-stat"><span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span><b>{unitValue(stat.value,stat.id)}</b></div>{:else}<p>Equip and configure items to calculate attributes.</p>{/each}</div>
|
||||
<div class="summary-section"><header><span>ACTIVE SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>{#each summary.activeBonuses as bonus}<div class="summary-bonus"><span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span><em>+{unitValue(bonus.value,bonus.statId)} {attribute(bonus.statId)?.name}</em></div>{:else}<p>Matching Brand Sets and Skill Chips activate bonuses here.</p>{/each}</div>
|
||||
<div class="summary-section"><header><span>EQUIPPED TALENTS</span><b>{summary.equippedTalents.length}</b></header>{#each summary.equippedTalents as equipped}<label class="summary-talent"><input type="checkbox" checked={draft.activeTalents.includes(equipped.key)} onchange={() => toggleTalent(equipped.key)}/><span><b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b><small>{equipped.source} · {draft.activeTalents.includes(equipped.key) ? 'TRIGGER ACTIVE' : 'CONDITIONAL'}</small><em>{equipped.description}</em>{#if equipped.id && talentCooldown(equipped.id)}<i class="talent-cooldown">{talentCooldown(equipped.id)}</i>{/if}</span></label>{:else}<p>Selected equipment talents appear here.</p>{/each}</div>
|
||||
<div class="summary-section"><header><span>EQUIPPED TALENTS</span><b>{summary.equippedTalents.length}</b></header>{#each summary.equippedTalents as equipped}<label class="summary-talent">{#if draft.calculationMode === 'custom'}<input type="checkbox" checked={(draft.conditionalEffects[equipped.key]?.mode ?? 'off') !== 'off'} onchange={() => toggleTalent(equipped.key)}/>{/if}<span><b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b><small>{equipped.source} · {draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : (draft.conditionalEffects[equipped.key]?.mode ?? 'off') !== 'off' ? 'INCLUDED' : 'CONDITIONAL'}</small><em>{equipped.description}</em>{#if equipped.id && talentCooldown(equipped.id)}<i class="talent-cooldown">{talentCooldown(equipped.id)}</i>{/if}</span></label>{:else}<p>Selected equipment talents appear here.</p>{/each}</div>
|
||||
</div>
|
||||
<p class="notice">Talent effects enter totals only while marked trigger active. Anonymous drafts remain on this device.</p>
|
||||
<p class="notice">{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.</p>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -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<string, unknown> => {
|
||||
try {
|
||||
const parsed = JSON.parse(String(value ?? '{}'));
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? parsed as Record<string, unknown>
|
||||
: {};
|
||||
} 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<string, any>();
|
||||
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': {
|
||||
|
||||
@@ -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<ReturnType<typeof getDatabase>
|
||||
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<SkillProfileInput>(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');
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
<div class="calculation-kpis">
|
||||
<div><span>ACTIVE REVISION</span><b>{activeRevision ? `R${activeRevision.version}` : 'NONE'}</b><small>{activeRevision?.name ?? 'NO ACTIVE RULES'}</small></div>
|
||||
<div><span>ATTRIBUTE PROFILES</span><b>{activeRevision?.profiles.length ?? 0}</b><small>TYPED BINDINGS</small></div>
|
||||
<div><span>WEAPON PROFILES</span><b>{activeRevision?.profiles.length ?? 0}</b><small>ATTRIBUTE BINDINGS</small></div>
|
||||
<div><span>SKILL PROFILES</span><b>{activeRevision?.skillProfiles.length ?? 0}</b><small>ABILITY BINDINGS</small></div>
|
||||
<div><span>READY CASES</span><b>{Number(data.calibrationSummary?.ready ?? 0)}</b><small>{Number(data.calibrationSummary?.predicted ?? 0)} WITH PREDICTIONS</small></div>
|
||||
</div>
|
||||
|
||||
<section class="calculation-script-disclosure" id="script-reference">
|
||||
<header>
|
||||
<div>
|
||||
<p class="eyebrow">GENERATED CALCULATION LANGUAGE</p>
|
||||
<h3>ACTIVE WEAPON DAMAGE SCRIPT</h3>
|
||||
<span>The script explains the ordered typed revision. Editors remain the source of calculation data.</span>
|
||||
</div>
|
||||
<div>
|
||||
<MechanicLanguageHelp />
|
||||
<button
|
||||
type="button"
|
||||
class="secondary"
|
||||
aria-expanded={scriptExpanded}
|
||||
aria-controls="active-calculation-script"
|
||||
onclick={() => scriptExpanded = !scriptExpanded}
|
||||
>{scriptExpanded ? 'HIDE SCRIPT' : 'VIEW SCRIPT'}</button>
|
||||
</div>
|
||||
</header>
|
||||
{#if scriptExpanded}
|
||||
<div id="active-calculation-script">
|
||||
{#if activeCalculationScript.length}
|
||||
<PseudoScript title={`RULES R${activeRevision.version} · WEAPON PIPELINE`} lines={activeCalculationScript} />
|
||||
{:else}
|
||||
<p class="empty-script">No active calculation revision is available.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if revisionEditing}
|
||||
<div class="dashboard-editor calculation-revision-editor">
|
||||
<div class="editor-title"><div><p class="eyebrow">{revisionEditing.id ? 'MODIFY DRAFT REVISION' : 'NEW DRAFT REVISION'}</p><h3>{revisionEditing.name || 'CALCULATION RULE SET'}</h3></div><button aria-label="Close revision editor" onclick={() => revisionEditing = null}>×</button></div>
|
||||
@@ -162,6 +242,7 @@
|
||||
<input type="hidden" name="basedOnRevisionId" value={revisionEditing.basedOnRevisionId}/>
|
||||
<input type="hidden" name="stagesJson" value={JSON.stringify(revisionEditing.stages)}/>
|
||||
<input type="hidden" name="profilesJson" value={JSON.stringify(revisionEditing.profiles)}/>
|
||||
<input type="hidden" name="skillProfilesJson" value={JSON.stringify(revisionEditing.skillProfiles)}/>
|
||||
<div class="calculation-revision-identity"><label class="field isac-record-name">REVISION NAME<input name="name" bind:value={revisionEditing.name} maxlength="120" required/></label><label class="field">NOTES<textarea name="notes" bind:value={revisionEditing.notes} maxlength="4000"></textarea></label></div>
|
||||
<div class="selection-head"><div><span>ORDERED CALCULATION STAGES</span><small>BASE AND FIRING-CYCLE POSITIONS ARE LOCKED</small></div><b>{revisionEditing.stages.length} STAGES</b></div>
|
||||
<div class="calculation-stage-list">{#each [...revisionEditing.stages].sort((left, right) => left.displayOrder - right.displayOrder) as stage, index}
|
||||
@@ -182,6 +263,18 @@
|
||||
<SegmentedChoice legend="CURVE" name={`profile-curve-${index}`} options={curveOptions} bind:value={profile.curveType}/>
|
||||
</section>
|
||||
{/each}</div>
|
||||
<div class="selection-head"><div><span>SKILL ATTRIBUTE PROFILES</span><small>ABILITY DAMAGE, CRITICAL, MULTISHOT, AND CADENCE BINDINGS</small></div><button type="button" class="secondary" onclick={addSkillProfile}>ADD SKILL PROFILE +</button></div>
|
||||
<div class="calculation-profile-list skill-profile-list">{#each revisionEditing.skillProfiles as profile, index}
|
||||
<section>
|
||||
<header><span>SKILL PROFILE {String(index + 1).padStart(2, '0')}</span><button type="button" class="moderation-action danger" onclick={() => revisionEditing && (revisionEditing.skillProfiles = revisionEditing.skillProfiles.filter((_, itemIndex) => itemIndex !== index))}>REMOVE</button></header>
|
||||
<div class="field"><span>ATTRIBUTE</span><SearchableAssignment items={data.attributes} mode="single" compact fieldName={`skill-profile-attribute-${index}`} subjectLabel="ATTRIBUTE" searchPlaceholder="Search Attributes…" bind:value={profile.statAttributeId}/></div>
|
||||
<div class="field"><span>SKILL CALCULATION STAGE</span><SearchableAssignment items={skillStageItems} mode="single" compact fieldName={`skill-profile-stage-${index}`} subjectLabel="STAGE" searchPlaceholder="Search skill stages…" bind:value={profile.stageKey}/></div>
|
||||
<div class="skill-profile-controls">
|
||||
<SegmentedChoice legend="OPERATOR" name={`skill-profile-operator-${index}`} options={skillOperatorOptions} bind:value={profile.operator}/>
|
||||
<label class="field">WEIGHT<input type="number" min="-100" max="100" step="any" bind:value={profile.weight} required/></label>
|
||||
</div>
|
||||
</section>
|
||||
{/each}</div>
|
||||
{#if form?.revisionError}<p class="error">{form.revisionError}</p>{/if}
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => revisionEditing = null}>CANCEL</button><button class="primary">SAVE DRAFT REVISION →</button></div>
|
||||
</PendingForm>
|
||||
@@ -225,7 +318,7 @@
|
||||
<article class:active={revision.status === 'active'}>
|
||||
<header><div><span>REVISION {revision.version}</span><h3>{revision.name}</h3></div><b>{revision.status.toUpperCase()}</b></header>
|
||||
<p>{revision.notes || 'No revision notes.'}</p>
|
||||
<footer><span>{revision.stages.length} STAGES · {revision.profiles.length} ATTRIBUTE PROFILES</span><div>{#if revision.status === 'draft'}<button class="secondary" onclick={() => revisionEditing = revisionFrom(revision, true)}>EDIT</button>{#if canPublish}<button class="primary" onclick={() => { publishingId = revision.id; publishConfirmed = false; }}>REVIEW + ACTIVATE</button>{/if}{/if}</div></footer>
|
||||
<footer><span>{revision.stages.length} STAGES · {revision.profiles.length} WEAPON · {revision.skillProfiles.length} SKILL PROFILES</span><div>{#if revision.status === 'draft'}<button class="secondary" onclick={() => revisionEditing = revisionFrom(revision, true)}>EDIT</button>{#if canPublish}<button class="primary" onclick={() => { publishingId = revision.id; publishConfirmed = false; }}>REVIEW + ACTIVATE</button>{/if}{/if}</div></footer>
|
||||
{#if publishingId === revision.id}
|
||||
<div class="publish-confirmation">
|
||||
<div><strong>ACTIVATE REVISION {revision.version}?</strong><span>Every saved and shared build will recalculate using these rules.</span></div>
|
||||
@@ -252,7 +345,7 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.module-toolbar>div:first-child{min-width:0;padding-right:12px}.calculation-actions{flex:0 0 auto}.calculation-actions,.calculation-revision-registry article footer div{display:flex;gap:7px;align-items:center}.calculation-kpis{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px;margin-bottom:9px}.calculation-kpis>div{display:grid;gap:2px;padding:10px;border:1px solid var(--shd-border);background:var(--shd-surface)}.calculation-kpis span,.calculation-kpis small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.calculation-kpis b{color:var(--shd-orange);font-size:22px}.calculation-revision-identity,.calibration-identity{display:grid;grid-template-columns:1.2fr 1.8fr;gap:8px}.calculation-stage-list{display:grid;gap:4px}.calculation-stage-list>div{display:grid;grid-template-columns:42px minmax(220px,1fr) minmax(170px,.7fr) 90px 76px;gap:7px;align-items:center;padding:6px;border:1px solid var(--shd-border);background:#0d1011}.calculation-stage-list>div>b{color:var(--shd-orange);text-align:center}.calculation-stage-list>div>span,.calculation-stage-list>div>em{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.stage-order-actions{display:grid;grid-template-columns:1fr 1fr;gap:3px}.stage-order-actions button{min-width:0;height:32px;padding:0}.calculation-profile-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.calculation-profile-list>section{display:grid;gap:7px;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.calculation-profile-list header{display:flex;justify-content:space-between;align-items:center}.calculation-profile-values{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.calibration-identity{grid-template-columns:1.4fr 1fr 1fr 1fr 1fr}.calibration-snapshot textarea{min-height:180px;font-family:var(--shd-font-mono)}.calibration-prediction textarea{min-height:90px;font-family:var(--shd-font-mono)}.calibration-observations{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px}.calculation-revision-registry{display:grid;gap:7px;margin:10px 0}.calculation-revision-registry>article{border:1px solid var(--shd-border);border-left:2px solid var(--shd-border);background:var(--shd-surface)}.calculation-revision-registry>article.active{border-left-color:var(--shd-orange)}.calculation-revision-registry article>header,.calculation-revision-registry article>footer{display:flex;justify-content:space-between;gap:8px;align-items:center;padding:9px}.calculation-revision-registry article>header{border-bottom:1px solid var(--shd-border)}.calculation-revision-registry h3,.calculation-revision-registry p{margin:0}.calculation-revision-registry article>p{padding:9px;color:var(--shd-text-muted)}.calculation-revision-registry article>footer{border-top:1px solid var(--shd-border);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.publish-confirmation{display:grid;grid-template-columns:1fr auto auto;gap:12px;align-items:center;padding:10px;border-top:1px solid var(--shd-orange);background:#1b160d}.publish-confirmation>div{display:grid}.publish-confirmation label{font-size:var(--shd-type-supporting)}.calibration-row{grid-template-columns:minmax(230px,1.4fr) .5fr .65fr .8fr .55fr 70px}
|
||||
@media(max-width:1000px){.calculation-profile-list{grid-template-columns:1fr}.calibration-identity,.calibration-observations{grid-template-columns:repeat(2,minmax(0,1fr))}.publish-confirmation{grid-template-columns:1fr}.calibration-row{grid-template-columns:1fr .5fr .6fr}.calibration-row>*:nth-child(4),.calibration-row>*:nth-child(5){display:none}}
|
||||
@media(max-width:650px){.calculation-actions{display:grid}.calculation-kpis{grid-template-columns:1fr}.calculation-revision-identity,.calibration-identity,.calibration-observations,.calculation-stage-list>div,.calculation-profile-values{grid-template-columns:1fr}.calculation-stage-list>div>b{text-align:left}.calculation-revision-registry article>header,.calculation-revision-registry article>footer{align-items:start;display:grid}.calibration-row{grid-template-columns:1fr 70px}.calibration-row>*:nth-child(2),.calibration-row>*:nth-child(3){display:none}}
|
||||
.module-toolbar>div:first-child{min-width:0;padding-right:12px}.calculation-actions{flex:0 0 auto}.calculation-actions,.calculation-revision-registry article footer div{display:flex;gap:7px;align-items:center}.calculation-kpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px;margin-bottom:9px}.calculation-kpis>div{display:grid;gap:2px;padding:10px;border:1px solid var(--shd-border);background:var(--shd-surface)}.calculation-kpis span,.calculation-kpis small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.calculation-kpis b{color:var(--shd-orange);font-size:22px}.calculation-script-disclosure{display:grid;gap:var(--shd-gap-sm);margin-bottom:9px;padding:var(--shd-gap-sm);border:1px solid var(--shd-border);border-top:2px solid var(--shd-success);background:var(--shd-surface)}.calculation-script-disclosure>header{display:flex;justify-content:space-between;align-items:start;gap:var(--shd-gap-md)}.calculation-script-disclosure>header>div:first-child{min-width:0}.calculation-script-disclosure>header>div:last-child{display:flex;gap:7px;flex:0 0 auto}.calculation-script-disclosure h3,.calculation-script-disclosure span,.empty-script{margin:0}.calculation-script-disclosure h3{font-size:var(--shd-type-body);letter-spacing:.7px}.calculation-script-disclosure span,.empty-script{color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}.empty-script{padding:9px;border:1px solid var(--shd-border-soft)}.calculation-revision-identity,.calibration-identity{display:grid;grid-template-columns:1.2fr 1.8fr;gap:8px}.calculation-stage-list{display:grid;gap:4px}.calculation-stage-list>div{display:grid;grid-template-columns:42px minmax(220px,1fr) minmax(170px,.7fr) 90px 76px;gap:7px;align-items:center;padding:6px;border:1px solid var(--shd-border);background:#0d1011}.calculation-stage-list>div>b{color:var(--shd-orange);text-align:center}.calculation-stage-list>div>span,.calculation-stage-list>div>em{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.stage-order-actions{display:grid;grid-template-columns:1fr 1fr;gap:3px}.stage-order-actions button{min-width:0;height:32px;padding:0}.calculation-profile-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.calculation-profile-list>section{display:grid;gap:7px;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.calculation-profile-list header{display:flex;justify-content:space-between;align-items:center}.calculation-profile-values{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.skill-profile-controls{display:grid;grid-template-columns:minmax(0,1fr) minmax(120px,.42fr);gap:7px;align-items:end}.calibration-identity{grid-template-columns:1.4fr 1fr 1fr 1fr 1fr}.calibration-snapshot textarea{min-height:180px;font-family:var(--shd-font-mono)}.calibration-prediction textarea{min-height:90px;font-family:var(--shd-font-mono)}.calibration-observations{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px}.calculation-revision-registry{display:grid;gap:7px;margin:10px 0}.calculation-revision-registry>article{border:1px solid var(--shd-border);border-left:2px solid var(--shd-border);background:var(--shd-surface)}.calculation-revision-registry>article.active{border-left-color:var(--shd-orange)}.calculation-revision-registry article>header,.calculation-revision-registry article>footer{display:flex;justify-content:space-between;gap:8px;align-items:center;padding:9px}.calculation-revision-registry article>header{border-bottom:1px solid var(--shd-border)}.calculation-revision-registry h3,.calculation-revision-registry p{margin:0}.calculation-revision-registry article>p{padding:9px;color:var(--shd-text-muted)}.calculation-revision-registry article>footer{border-top:1px solid var(--shd-border);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.publish-confirmation{display:grid;grid-template-columns:1fr auto auto;gap:12px;align-items:center;padding:10px;border-top:1px solid var(--shd-orange);background:#1b160d}.publish-confirmation>div{display:grid}.publish-confirmation label{font-size:var(--shd-type-supporting)}.calibration-row{grid-template-columns:minmax(230px,1.4fr) .5fr .65fr .8fr .55fr 70px}
|
||||
@media(max-width:1000px){.calculation-kpis{grid-template-columns:repeat(2,minmax(0,1fr))}.calculation-profile-list{grid-template-columns:1fr}.calibration-identity,.calibration-observations{grid-template-columns:repeat(2,minmax(0,1fr))}.publish-confirmation{grid-template-columns:1fr}.calibration-row{grid-template-columns:1fr .5fr .6fr}.calibration-row>*:nth-child(4),.calibration-row>*:nth-child(5){display:none}}
|
||||
@media(max-width:650px){.calculation-actions{display:grid}.calculation-kpis{grid-template-columns:1fr}.calculation-script-disclosure>header,.calculation-script-disclosure>header>div:last-child{display:grid}.calculation-script-disclosure>header>div:last-child>*{width:100%;min-height:44px}.calculation-revision-identity,.calibration-identity,.calibration-observations,.calculation-stage-list>div,.calculation-profile-values,.skill-profile-controls{grid-template-columns:1fr}.calculation-stage-list>div>b{text-align:left}.calculation-revision-registry article>header,.calculation-revision-registry article>footer{align-items:start;display:grid}.calibration-row{grid-template-columns:1fr 70px}.calibration-row>*:nth-child(2),.calibration-row>*:nth-child(3){display:none}}
|
||||
</style>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<string, Record<string, string>> = {
|
||||
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<typeof parseConditionalRulePayload>) =>
|
||||
rule.clauses.flatMap((clause) => clause.outcomes);
|
||||
const descriptionOutcomes = (rule: ReturnType<typeof parseConditionalRulePayload>) =>
|
||||
mechanicOutcomes(rule).filter((outcome) => outcome.descriptionSlot)
|
||||
.sort((left, right) => Number(left.descriptionSlot) - Number(right.descriptionSlot));
|
||||
|
||||
async function 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 && !before?.record) return fail(404, { protocolError: 'OS Protocol not found.' });
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
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);
|
||||
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 }) => {
|
||||
|
||||
@@ -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<any | null>(null);
|
||||
let profileEditing = $state<any | null>(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 @@
|
||||
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY PROTOCOL' : 'NEW PROTOCOL'}</p><h3>{editing.id ? editing.name : 'OS PROTOCOL DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
|
||||
<PendingForm action="?/save" enctype="multipart/form-data">
|
||||
<input type="hidden" name="id" value={editing.id}/>
|
||||
<input type="hidden" name="talentEffects" value={JSON.stringify(editing.talent.effects.map((effect: TalentEffect) => ({ statId: effect.stat_attribute_id, baseValue: effect.base_value })))} />
|
||||
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)} />
|
||||
<div class="protocol-identity-grid">
|
||||
<div class="icon-editor-identity"><IconUploadField currentUrl={editing.iconUrl} label="OS PROTOCOL ICON" resetToken={iconResetToken}/><label class="field">PROTOCOL NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label></div>
|
||||
<div class="protocol-classification-stack">
|
||||
@@ -152,16 +167,19 @@
|
||||
<div class="protocol-primary-editor"><div class="field"><span>PRIMARY STAT</span><SearchableAssignment items={data.stats} fieldName="primaryStatId" mode="single" compact subjectLabel="PRIMARY ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={editing.primaryEffect.stat_attribute_id}/></div><label class="field">LEVEL 1 BASE VALUE<input name="primaryBaseValue" type="number" step="any" min="0" bind:value={editing.primaryEffect.base_value} required/></label><div><span>LEVEL {protocolLevel} PREVIEW</span><b>{format(levelValue(editing.primaryEffect.base_value, protocolLevel), editing.primaryEffect.stat_attribute_id)}</b><small>LEVEL 50 · {format(levelValue(editing.primaryEffect.base_value, 50), editing.primaryEffect.stat_attribute_id)}</small></div></div>
|
||||
|
||||
<section class="protocol-talent">
|
||||
<div class="selection-head"><div><span>UNIQUE PROTOCOL TALENT</span><small>OWNED EXCLUSIVELY BY THIS PROTOCOL · FIVE LINEAR UPGRADES</small></div><b>{editing.talent.id ? 'LINKED RECORD' : 'NEW RECORD'}</b></div>
|
||||
<div class="selection-head"><div><span>UNIQUE PROTOCOL TALENT</span><small>OWNED EXCLUSIVELY BY THIS PROTOCOL · FIVE LINEAR UPGRADES FOR MARKED VALUES</small></div><b>{editing.talent.id ? 'LINKED RECORD' : 'NEW RECORD'}</b></div>
|
||||
<div class="protocol-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label><label class="field">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={editing.talent.description_template} placeholder="While active, gain +{1} damage…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values and units.</small></label></div>
|
||||
<div class="protocol-upgrade-preview"><div class="diamond-control" role="group" aria-label="Preview talent upgrade level"><button type="button" class:active={talentUpgrade === 0} aria-pressed={talentUpgrade === 0} onclick={() => talentUpgrade = 0}>BASE</button>{#each [1, 2, 3, 4, 5] as upgrade}<button type="button" class:active={talentUpgrade >= upgrade} aria-label={`Preview talent at upgrade ${upgrade}`} aria-pressed={talentUpgrade === upgrade} onclick={() => talentUpgrade = upgrade}><span>◆</span><small>{upgrade}</small></button>{/each}</div><div><span>UPGRADE {talentUpgrade} / 5 · {talentUpgrade === 5 ? '2× EFFECT VALUE' : `${Math.round((1 + talentUpgrade / 5) * 100)}% EFFECT VALUE`}</span><p>{talentPreview(talentUpgrade) || 'Talent description preview'}</p></div></div>
|
||||
<div class="protocol-talent-grid">
|
||||
<div><div class="selection-head"><div><span>ACTIVATION TRIGGERS</span><small>REUSABLE TRIGGER DEFINITIONS</small></div><b>{editing.talent.triggerIds.length}</b></div><SearchableAssignment items={data.triggers} fieldName="triggerIds" bind:selected={editing.talent.triggerIds}/></div>
|
||||
<div><div class="selection-head"><div><span>TALENT EFFECTS</span><small>BASE VALUE AT ZERO UPGRADES</small></div><button type="button" class="row-action" onclick={addTalentEffect} disabled={editing.talent.effects.length >= 12}>ADD EFFECT +</button></div><div class="protocol-talent-effects">{#each editing.talent.effects as effect, index}<div><strong>{index + 1}</strong><SearchableAssignment items={data.stats} mode="single" compact subjectLabel="ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={effect.stat_attribute_id}/><input type="number" step="any" bind:value={effect.base_value} placeholder="BASE VALUE" required/><span>{format(upgradeValue(effect.base_value, 5), effect.stat_attribute_id)} AT +5</span><button type="button" aria-label={`Remove talent effect ${index + 1}`} onclick={() => removeTalentEffect(index)} disabled={editing.talent.effects.length === 1}>×</button></div>{/each}</div></div>
|
||||
</div>
|
||||
<div class="protocol-upgrade-preview"><div class="diamond-control" role="group" aria-label="Preview talent upgrade level"><button type="button" class:active={talentUpgrade === 0} aria-pressed={talentUpgrade === 0} onclick={() => talentUpgrade = 0}>BASE</button>{#each [1, 2, 3, 4, 5] as upgrade}<button type="button" class:active={talentUpgrade >= upgrade} aria-label={`Preview talent at upgrade ${upgrade}`} aria-pressed={talentUpgrade === upgrade} onclick={() => talentUpgrade = upgrade}><span>◆</span><small>{upgrade}</small></button>{/each}</div><div><span>UPGRADE {talentUpgrade} / 5 · {talentUpgrade === 5 ? '2× SCALABLE VALUES' : `${Math.round((1 + talentUpgrade / 5) * 100)}% SCALABLE VALUES`}</span><p>{talentPreview(talentUpgrade) || 'Talent description preview'}</p></div></div>
|
||||
<MechanicProgramEditor
|
||||
attributes={data.stats}
|
||||
valueColumns={talentValueColumns}
|
||||
idPrefix={`protocol-talent-${editing.talent.id || 'new'}`}
|
||||
allowProtocolUpgradeScaling
|
||||
bind:rule={editing.talent.conditionalRule}
|
||||
/>
|
||||
</section>
|
||||
{#if form?.protocolError}<p class="error">{form.protocolError}</p>{/if}
|
||||
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE PROTOCOL</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!configuredProfile() || data.stats.length === 0 || data.triggers.length === 0}>SAVE PROTOCOL →</button></div></div>
|
||||
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE PROTOCOL</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!configuredProfile() || data.stats.length === 0}>SAVE PROTOCOL →</button></div></div>
|
||||
</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -172,7 +190,7 @@
|
||||
<div class="dashboard-table protocol-dashboard-table">
|
||||
<div class="dashboard-row protocol-row table-heading"><SortableColumnHeader label="OS PROTOCOL" column="name" sort={data.sort}/><SortableColumnHeader label="QUALITY" column="quality" sort={data.sort}/><SortableColumnHeader label="TYPE" column="type" sort={data.sort}/><SortableColumnHeader label="UNIQUE PRIMARY" column="primary" sort={data.sort}/><SortableColumnHeader label="UNIQUE TALENT" column="talent" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
||||
{#each data.protocols as protocol}
|
||||
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1} class:pending-record={protocol.pending} class:registry-row-editing={editing?.id === protocol.id}><div class="registry-icon-identity"><ItemIcon src={protocol.iconUrl} alt=""/><div><strong>{protocol.name}</strong><small>{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}</small></div></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{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'}</span><div><strong>{protocol.talent?.name ?? 'NOT CONFIGURED'}</strong><small><span class="mini-diamonds">◆◆◆◆◆</span> 5 UPGRADES · 2× MAX</small></div><b class:pending={protocol.pending} class="state-dot">{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if protocol.pending}<span></span>{:else}<button aria-expanded={editing?.id === protocol.id} aria-controls={`protocol-editor-${protocol.id}`} onclick={() => edit(protocol)}>EDIT</button>{/if}</div>
|
||||
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1} class:pending-record={protocol.pending} class:registry-row-editing={editing?.id === protocol.id}><div class="registry-icon-identity"><ItemIcon src={protocol.iconUrl} alt=""/><div><strong>{protocol.name}</strong><small>{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}</small></div></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{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'}</span><div><strong>{protocol.talent?.name ?? 'NOT CONFIGURED'}</strong><small><span class="mini-diamonds">◆◆◆◆◆</span> {protocol.talent?.conditionalRule?.clauses?.length || protocol.pending ? 'MODELED · ' : 'CONFIG REQUIRED · '}5 UPGRADES</small></div><b class:pending={protocol.pending} class="state-dot">{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if protocol.pending}<span></span>{:else}<button aria-expanded={editing?.id === protocol.id} aria-controls={`protocol-editor-${protocol.id}`} onclick={() => edit(protocol)}>EDIT</button>{/if}</div>
|
||||
{#if editing?.id === protocol.id}<RegistryInlineEditor id={`protocol-editor-${protocol.id}`} label={`Edit ${protocol.name}`}>{@render protocolEditor()}</RegistryInlineEditor>{/if}
|
||||
{:else}<div class="empty-dashboard"><strong>NO OS PROTOCOLS REGISTERED</strong><span>Configure shared secondary profiles, then add the first protocol.</span></div>{/each}
|
||||
</div>
|
||||
|
||||
@@ -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<typeof parseConditionalRulePayload>) =>
|
||||
rule.clauses.flatMap((clause) => clause.outcomes);
|
||||
const descriptionOutcomes = (rule: ReturnType<typeof parseConditionalRulePayload>) =>
|
||||
mechanicOutcomes(rule).filter((outcome) => outcome.descriptionSlot)
|
||||
.sort((left, right) => Number(left.descriptionSlot) - Number(right.descriptionSlot));
|
||||
|
||||
async function 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,21 +228,22 @@ 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 ?? '');
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
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(?,?,?,?,?)
|
||||
@@ -244,14 +254,30 @@ export const actions: Actions = {
|
||||
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);
|
||||
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 }) => {
|
||||
|
||||
@@ -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<any | null>(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 @@
|
||||
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY CHIP SET' : 'NEW CHIP SET'}</p><h3>{editing.id ? editing.name : 'SKILL CHIP DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
|
||||
<PendingForm action="?/save" enctype="multipart/form-data">
|
||||
<input type="hidden" name="id" value={editing.id}/>
|
||||
<input type="hidden" name="talentEffects" value={JSON.stringify(editing.talent.effects.map((effect: TalentEffect) => ({ statId: effect.stat_attribute_id, minValue: effect.min_value, maxValue: effect.max_value })))}/>
|
||||
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)}/>
|
||||
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Only these qualities participate in chip scaling.</small></div></div>
|
||||
<section class="skill-chip-ability-assignment">
|
||||
<div class="selection-head"><div><span>SPECIALIZATION ABILITY</span><small>EXCLUSIVE RELATIONSHIP · THREE ACTIVE SKILL CHIPS PER ABILITY</small></div><b>{editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 ASSIGNED` : 'REQUIRED'}</b></div>
|
||||
@@ -99,16 +113,19 @@
|
||||
</div>
|
||||
|
||||
<section class="skill-chip-talent">
|
||||
<div class="selection-head"><div><span>3-PIECE TALENT</span><small>QUALITY-SCALED EFFECTS · SHARED TRIGGERS AND DESCRIPTION</small></div><b>{editing.talent.effects.length} EFFECT{editing.talent.effects.length === 1 ? '' : 'S'}</b></div>
|
||||
<div class="selection-head"><div><span>3-PIECE TALENT</span><small>QUALITY-SCALED OUTCOMES · TYPED TRIGGER AND DESCRIPTION</small></div><b>{previewOutcomes().length} OUTCOME{previewOutcomes().length === 1 ? '' : 'S'}</b></div>
|
||||
<div class="skill-chip-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label><label class="field">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={editing.talent.description_template} placeholder="While active, gain +{1} Skill Damage…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for scaled effect values and units.</small></label></div>
|
||||
<div class="skill-chip-preview"><SegmentedChoice legend="QUALITY MIX PREVIEW" name="qualityPreview" options={qualitySteps} bind:value={highEndCount}/><div><span>{3 - Number(highEndCount)} SUPERIOR · {highEndCount} HIGH-END</span><p>{talentPreview() || 'Talent description preview'}</p></div></div>
|
||||
<div class="skill-chip-talent-grid">
|
||||
<div><div class="selection-head"><div><span>ACTIVATION TRIGGERS</span><small>REUSABLE TRIGGER DEFINITIONS</small></div><b>{editing.talent.triggerIds.length}</b></div><SearchableAssignment items={data.triggers} fieldName="triggerIds" bind:selected={editing.talent.triggerIds}/></div>
|
||||
<div><div class="selection-head"><div><span>TALENT EFFECTS</span><small>MINIMUM TO MAXIMUM ACROSS QUALITY MIX</small></div><button type="button" class="row-action" onclick={addEffect} disabled={editing.talent.effects.length >= 12}>ADD EFFECT +</button></div><div class="skill-chip-effects">{#each editing.talent.effects as effect, index}<div><strong>{index + 1}</strong><SearchableAssignment items={data.stats} mode="single" compact subjectLabel="ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={effect.stat_attribute_id}/><input type="number" step="any" bind:value={effect.min_value} placeholder="MIN" required/><input type="number" step="any" bind:value={effect.max_value} placeholder="MAX" required/><button type="button" aria-label={`Remove talent effect ${index + 1}`} onclick={() => removeEffect(index)} disabled={editing.talent.effects.length === 1}>×</button></div>{/each}</div></div>
|
||||
</div>
|
||||
<MechanicProgramEditor
|
||||
attributes={data.stats}
|
||||
allowAssignedAbility
|
||||
valueColumns={talentValueColumns}
|
||||
idPrefix={`skill-chip-talent-${editing.talent.id || 'new'}`}
|
||||
bind:rule={editing.talent.conditionalRule}
|
||||
/>
|
||||
</section>
|
||||
{#if form?.skillChipError}<p class="error">{form.skillChipError}</p>{/if}
|
||||
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction={Number(editing.active) ? '?/archive' : '?/unarchive'} name="id" value={editing.id}>{Number(editing.active) ? 'ARCHIVE CHIP SET' : 'UNARCHIVE CHIP SET'}</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!editing.abilityId || !editing.primaryStatIds.length || editing.secondaryStatIds.length < 2 || !data.rollStats.length || !data.triggers.length}>SAVE SKILL CHIP →</button></div></div>
|
||||
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction={Number(editing.active) ? '?/archive' : '?/unarchive'} name="id" value={editing.id}>{Number(editing.active) ? 'ARCHIVE CHIP SET' : 'UNARCHIVE CHIP SET'}</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!editing.abilityId || !editing.primaryStatIds.length || editing.secondaryStatIds.length < 2 || !data.rollStats.length}>SAVE SKILL CHIP →</button></div></div>
|
||||
</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -119,7 +136,7 @@
|
||||
<div class="dashboard-table">
|
||||
<div class="dashboard-row skill-chip-row table-heading"><SortableColumnHeader label="SKILL CHIP" column="name" sort={data.sort}/><SortableColumnHeader label="ABILITY" column="ability" sort={data.sort}/><span>QUALITY</span><SortableColumnHeader label="2-PIECE BONUS" column="twoPiece" sort={data.sort}/><SortableColumnHeader label="3-PIECE TALENT" column="talent" sort={data.sort}/><SortableColumnHeader label="ROLL POOLS" column="rollPools" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
||||
{#each data.sets as item}
|
||||
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>EXCLUSIVE ABILITY</small></div><span class="skill-chip-quality-pair"><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{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'}</span><div><strong>{item.talent?.name ?? 'NOT CONFIGURED'}</strong><small>{item.talent?.effects.length ?? 0} SCALED EFFECTS</small></div><span>{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
|
||||
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>EXCLUSIVE ABILITY</small></div><span class="skill-chip-quality-pair"><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{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'}</span><div><strong>{item.talent?.name ?? 'NOT CONFIGURED'}</strong><small>{item.talent?.conditionalRule?.clauses?.length || item.pending ? 'MODELED · QUALITY SCALED' : 'CONFIG REQUIRED'}</small></div><span>{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
|
||||
{#if editing?.id === item.id}<RegistryInlineEditor id={`skill-chip-editor-${item.id}`} label={`Edit ${item.name}`}>{@render skillChipEditor()}</RegistryInlineEditor>{/if}
|
||||
{:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first Superior and High-end Skill Chip definition.</span></div>{/each}
|
||||
</div>
|
||||
|
||||
@@ -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 = <T>(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<string, unknown>
|
||||
: {};
|
||||
} 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<AbilityEffectInput>(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,10 +726,38 @@ 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);
|
||||
}
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
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);
|
||||
@@ -645,6 +769,12 @@ export const actions: Actions = {
|
||||
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;
|
||||
}
|
||||
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,11 +1123,6 @@ export const actions: Actions = {
|
||||
}
|
||||
}
|
||||
}
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
await applyIconMutation(database, 'specialization_node', nodeId, iconMutation, operator.id);
|
||||
const readiness = await refreshSpecializationFocusReady(database, focusId);
|
||||
await recordIsacAudit({
|
||||
@@ -951,6 +1133,12 @@ export const actions: Actions = {
|
||||
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;
|
||||
}
|
||||
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 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 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 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 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() };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<SpecializationEditor | null>(null);
|
||||
let focusEditing = $state<FocusEditor | null>(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 @@
|
||||
<PendingForm action="?/saveAbility" enctype="multipart/form-data">
|
||||
<input type="hidden" name="id" value={abilityEditing.id}/>
|
||||
<input type="hidden" name="effectsJson" value={JSON.stringify(abilityEditing.effects)}/>
|
||||
<input type="hidden" name="attackJson" value={JSON.stringify(abilityEditing.attack)}/>
|
||||
<div class="isac-record-identity specialization-record-identity">
|
||||
<IconUploadField currentUrl={abilityEditing.iconUrl} label="ABILITY ICON" resetToken={iconResetToken}/>
|
||||
<div>
|
||||
@@ -468,6 +479,27 @@
|
||||
<label class="field">COOLDOWN · SECONDS<input name="cooldownSeconds" type="number" min="0.01" step="any" bind:value={abilityEditing.cooldownSeconds} placeholder="OPTIONAL"/></label>
|
||||
<div><span>AVAILABILITY</span><strong>IMMEDIATE · NO POINT OR MATERIAL COST</strong><small>Assigned ability slots 1 and 3 are mirrored into the top-left of the focus tree.</small></div>
|
||||
</div>
|
||||
<div class="specialization-attack-profile">
|
||||
<div class="selection-head"><div><span>INTRINSIC ATTACK PROFILE</span><small>UNMODIFIED DAMAGE BEFORE ENGINEERING, ATTRIBUTES, TALENTS, AND TREE UNLOCKS</small></div><b>{abilityEditing.attack.enabled ? 'DAMAGING ABILITY' : 'NO DIRECT DAMAGE'}</b></div>
|
||||
<SegmentedChoice legend="DAMAGE MODEL" name="ability-damage-model" options={[{value:'disabled',label:'NO DAMAGE'},{value:'enabled',label:'MODELS DAMAGE'}]} value={abilityEditing.attack.enabled ? 'enabled' : 'disabled'} onchange={(values) => setAbilityAttackEnabled(values[0] === 'enabled')}/>
|
||||
{#if abilityEditing.attack.enabled}
|
||||
<div class="specialization-attack-grid">
|
||||
<label class="field">UNMODIFIED BASE DAMAGE<input type="number" min="0.01" step="any" bind:value={abilityEditing.attack.baseDamage} required/></label>
|
||||
<SegmentedChoice legend="DAMAGE TYPE" name="ability-damage-type" options={damageTypeOptions} bind:value={abilityEditing.attack.damageType} required/>
|
||||
<SegmentedChoice legend="DELIVERY" name="ability-delivery" options={deliveryOptions} bind:value={abilityEditing.attack.delivery} required/>
|
||||
<label class="field">{abilityEditing.attack.delivery === 'periodic' ? 'HITS PER TICK' : 'HITS PER ACTIVATION'}<input type="number" min="1" step="1" bind:value={abilityEditing.attack.hitsPerActivation} required/></label>
|
||||
{#if abilityEditing.attack.delivery === 'periodic'}
|
||||
<label class="field">TICK INTERVAL · SECONDS<input type="number" min="0.01" step="any" bind:value={abilityEditing.attack.tickIntervalSeconds} required/></label>
|
||||
<label class="field">DAMAGING DURATION · SECONDS<input type="number" min="0.01" step="any" bind:value={abilityEditing.attack.damageDurationSeconds} required/></label>
|
||||
{/if}
|
||||
<label class="field">CAST TIME · SECONDS<input type="number" min="0" step="any" bind:value={abilityEditing.attack.castSeconds} required/></label>
|
||||
<label class="field">TARGET CAP<input type="number" min="1" step="1" bind:value={abilityEditing.attack.targetCap} required/></label>
|
||||
<SegmentedChoice legend="CHARGE POLICY" name="ability-charge-policy" options={chargePolicyOptions} bind:value={abilityEditing.attack.chargePolicy} required/>
|
||||
{#if abilityEditing.attack.chargePolicy !== 'none'}<label class="field">CHARGE COUNT<input type="number" min="1" step="1" bind:value={abilityEditing.attack.chargeCount} required/></label>{/if}
|
||||
</div>
|
||||
<p class="calculation-note">All damaging skills can critically hit and Multi-Shot. Skill attacks do not headshot. Periodic damage reads active modifiers again on every tick.</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="selection-head"><div><span>STRUCTURED COMBAT FACTS</span><small>ATTRIBUTE REFERENCES OR CUSTOM DISPLAY VALUES</small></div><b>{abilityEditing.effects.length} EFFECTS</b></div>
|
||||
<StructuredEffectEditor bind:effects={abilityEditing.effects} attributes={data.attributes} idPrefix="ability-effect" addLabel="ADD COMBAT FACT" allowTargetLabelFallback/>
|
||||
{#if form?.abilityError}<p class="error">{form.abilityError}</p>{/if}
|
||||
@@ -493,7 +525,7 @@
|
||||
<label class="field isac-record-name">NAME<input name="name" bind:value={combatEffectEditing.name} maxlength="100" required placeholder="Relentless"/></label>
|
||||
<label class="field combat-effect-template">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={combatEffectEditing.descriptionTemplate} maxlength="2400" required placeholder={'Grants {1} Weapon Damage and {2} Critical Damage for {duration} seconds, up to {maxStacks} stacks.'}></textarea></label>
|
||||
</div>
|
||||
<p class="configuration-note">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.</p>
|
||||
<p class="configuration-note">Use {`{duration}`}, {`{maxStacks}`}, and {`{1}`}, {`{2}`} for ordered calculation outcomes. Each tree rank owns its event branches, lifecycle, scope, and values.</p>
|
||||
{#if form?.combatEffectError}<p class="error">{form.combatEffectError}</p>{/if}
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => combatEffectEditing = null}>CANCEL</button><button class="primary">SAVE COMBAT EFFECT →</button></div>
|
||||
</PendingForm>
|
||||
@@ -538,25 +570,12 @@
|
||||
<section class="rank-combat-effect">
|
||||
<header><div><span>EFFECT {combatIndex + 1}</span><small>{combatEffectById(combatEffect.effectId)?.name ?? 'SELECT SHARED EFFECT'}</small></div><button type="button" class="moderation-action danger" onclick={() => removeRankCombatEffect(rank, combatIndex)}>REMOVE</button></header>
|
||||
<div class="field"><span>SHARED EFFECT</span><SearchableAssignment items={combatEffectList(String(focusById(nodeEditing.focusId)?.specialization_id ?? ''))} mode="single" compact fieldName={`rank-${rank.rank}-combat-${combatIndex}`} subjectLabel="COMBAT EFFECT" searchPlaceholder="Search shared effects…" bind:value={combatEffect.effectId}/></div>
|
||||
<div class="combat-trigger-grid">
|
||||
<label class="field">ENEMY HITS<input type="number" min="1" max="1000" step="1" bind:value={combatEffect.triggerCount} required/></label>
|
||||
<label class="field">WITHIN · SECONDS<input type="number" min="0.01" max="3600" step="any" bind:value={combatEffect.triggerWindowSeconds} required/></label>
|
||||
<label class="field">BUFF DURATION · SECONDS<input type="number" min="0.01" max="86400" step="any" bind:value={combatEffect.durationSeconds} required/></label>
|
||||
<label class="field">MAX STACKS<input type="number" min="1" max="1000" step="1" bind:value={combatEffect.maxStacks} required/></label>
|
||||
<label class="field">STACKS PER TRIGGER<input type="number" min="1" max={Number(combatEffect.maxStacks) || 1} step="1" bind:value={combatEffect.stacksPerTrigger} required/></label>
|
||||
</div>
|
||||
<div class="combat-lifecycle-facts"><span>COUNTER <b>RESETS AFTER PROC</b></span><span>STACK GAIN <b>REFRESHES TIMER</b></span><span>EXPIRY <b>CLEARS ALL STACKS</b></span></div>
|
||||
<div class="combat-modifier-list">
|
||||
{#each combatEffect.modifiers as modifier, modifierIndex}
|
||||
<div>
|
||||
<div class="field"><span>ATTRIBUTE PER STACK</span><SearchableAssignment items={data.attributes} mode="single" compact fieldName={`rank-${rank.rank}-combat-${combatIndex}-modifier-${modifierIndex}`} subjectLabel="ATTRIBUTE" searchPlaceholder="Search Attributes…" bind:value={modifier.statId}/></div>
|
||||
<label class="field">VALUE PER STACK<input type="number" step="any" bind:value={modifier.valuePerStack} required/></label>
|
||||
<SegmentedChoice legend="UNIT" name={`rank-${rank.rank}-combat-${combatIndex}-modifier-${modifierIndex}-unit`} options={unitOptions} columns={5} bind:value={modifier.unit}/>
|
||||
<button type="button" class="moderation-action danger" disabled={combatEffect.modifiers.length <= 1} onclick={() => removeCombatModifier(combatEffect, modifierIndex)}>REMOVE</button>
|
||||
</div>
|
||||
{/each}
|
||||
<button type="button" class="secondary" onclick={() => addCombatModifier(combatEffect)}>ADD ATTRIBUTE MODIFIER +</button>
|
||||
</div>
|
||||
<MechanicProgramEditor
|
||||
attributes={data.attributes}
|
||||
valueColumns={conditionalValueColumns}
|
||||
idPrefix={`specialization-rank-${rank.rank}-conditional-${combatIndex}`}
|
||||
bind:rule={combatEffect.conditionalRule}
|
||||
/>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -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<typeof parseConditionalRulePayload>) => {
|
||||
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,30 +182,60 @@ 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;
|
||||
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') {
|
||||
@@ -234,13 +257,39 @@ export const actions: Actions = {
|
||||
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);
|
||||
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, triggerIds, effects: sharedEffects } });
|
||||
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;
|
||||
}
|
||||
return { talentSaved: true, talentMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<any>(null), trigger = $state<any>(null), uptime = $state(false);
|
||||
let talent = $state<any>(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;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Talents — SHD.build admin</title></svelte:head>
|
||||
<div class="module-toolbar"><div><p class="eyebrow">TALENT REGISTRY</p><h2>TALENTS</h2><p>Shared behavior with tier-specific values, reusable triggers, and attribute effects.</p></div><div><button class="secondary" onclick={blankTrigger}>ADD TRIGGER +</button><button class="primary" onclick={() => talent = blankTalent()}>ADD TALENT +</button></div></div>
|
||||
<div class="module-toolbar"><div><p class="eyebrow">TALENT REGISTRY</p><h2>TALENTS</h2><p>Tier-specific Talent values and source-owned combat mechanics.</p></div><div><a class="secondary" href="/isac/mechanic-reference">MECHANIC REFERENCE →</a><button class="primary" onclick={() => talent = blankTalent()}>ADD TALENT +</button></div></div>
|
||||
<KpiFilters className="talent-kpis" active={data.filter} items={[{value:'',label:'TALENTS',count:data.summary.total,description:'Show all definitions'},{value:'slot1',label:'TOP / TALENT 1',count:data.summary.slot1,description:'First-slot weapon talents'},{value:'slot2',label:'BOTTOM / TALENT 2',count:data.summary.slot2,description:'Second-slot weapon talents'},{value:'exotic',label:'EXOTIC',count:data.summary.exotic,description:'Exotic-only talents'},{value:'backpack',label:'BACKPACK',count:data.summary.backpack,description:'Backpack talents'},{value:'body-armor',label:'BODY ARMOR',count:data.summary.bodyArmor,description:'Body armor talents'}]}/>
|
||||
|
||||
{#if trigger}
|
||||
<div class="dashboard-editor"><div class="editor-title"><div><p class="eyebrow">{trigger.id ? 'MODIFY TRIGGER' : 'NEW TRIGGER'}</p><h3>{trigger.id ? trigger.name : 'TRIGGER DEFINITION'}</h3></div><button aria-label="Close trigger editor" onclick={() => trigger = null}>×</button></div><PendingForm action="?/saveTrigger"><input type="hidden" name="id" value={trigger.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={trigger.name} required/></label><label class="field">DESCRIPTION<textarea name="description" bind:value={trigger.description} required></textarea></label><label class="field uptime-field"><span><input name="supportsUptime" type="checkbox" bind:checked={uptime}/> ENABLE THEORETICAL UPTIME</span><div><input name="minUptime" type="number" min="0" max="100" bind:value={trigger.min_uptime} disabled={!uptime} placeholder="MIN %"/><input name="maxUptime" type="number" min="0" max="100" bind:value={trigger.max_uptime} disabled={!uptime} placeholder="MAX %"/></div></label><p class="script-note"><b>CALCULATION SCRIPT</b><span>Reserved for the future calculation engine</span></p></div>{#if form?.triggerError}<p class="error">{form.triggerError}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => trigger = null}>CANCEL</button><button class="primary">SAVE TRIGGER →</button></div></PendingForm></div>
|
||||
{/if}
|
||||
|
||||
{#snippet talentEditor()}
|
||||
{#if talent}
|
||||
<div class="dashboard-editor">
|
||||
<div class="editor-title"><div><p class="eyebrow">{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}</p><h3>{talent.id ? talent.name : 'TALENT DEFINITION'}</h3></div><button aria-label="Close talent editor" onclick={() => talent = null}>×</button></div>
|
||||
<PendingForm action="?/saveTalent" enctype="multipart/form-data">
|
||||
<input type="hidden" name="id" value={talent.id}/><input type="hidden" name="effects" value={JSON.stringify(talent.effects)}/>
|
||||
<input type="hidden" name="id" value={talent.id}/><input type="hidden" name="conditionalRule" value={JSON.stringify(talent.conditionalRule)}/>
|
||||
<div class="talent-icon-identity"><IconUploadField currentUrl={talent.iconUrl} label="TALENT ICON" resetToken={iconResetToken}/><div class="talent-identity"><label class="field">NAME<input name="name" bind:value={talent.name} required/></label><SegmentedChoice legend="CATEGORY / SLOT" name="category" options={categoryOptions} bind:value={talent.category} required/></div></div>
|
||||
<div class="talent-mechanics-grid"><label class="field">COOLDOWN · SECONDS<input name="cooldownSeconds" type="number" min="0.01" step="any" bind:value={talent.cooldown_seconds} placeholder="NOT DEFINED"/></label><label class="field">DURATION · SECONDS<input name="durationSeconds" type="number" min="0.01" step="any" bind:value={talent.duration_seconds} placeholder="NOT DEFINED"/></label><label class="field">MAX STACKS<input name="maxStacks" type="number" min="1" step="1" bind:value={talent.max_stacks} placeholder="NOT DEFINED"/></label></div>
|
||||
<label class="field template-field">SHARED DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={talent.descriptionTemplate} placeholder={'Hitting an enemy {1} times grants +{2} for {duration}...'} required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values. Use {'{duration}'} and {'{max_stacks}'} for defined mechanics. Cooldown is displayed separately.</small></label>
|
||||
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{preview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{preview('II') || 'Description preview'}</p></div></div>
|
||||
<p class="label">SHARED ACTIVATION TRIGGERS</p>
|
||||
<div class="trigger-list shared-trigger-list">{#each data.triggers.filter((item) => Number(item.active) === 1) as item}<div class:pending-reference={item.pending}><label><input name="triggerIds" type="checkbox" value={item.id} checked={talent.triggerIds.includes(String(item.id))} onchange={(event) => toggleTrigger(String(item.id), event.currentTarget.checked)}/> <span>{item.name}</span></label>{#if item.pending}<b class="pending-reference-label">PENDING · {item.proposerName}</b>{:else}<button type="button" onclick={() => editTrigger(item)}>EDIT</button>{/if}</div>{/each}</div>
|
||||
<div class="effects-head"><p class="label">SHARED ATTRIBUTE EFFECTS · TIER VALUES</p><button type="button" onclick={addEffect}>ADD EFFECT +</button></div>
|
||||
<div class="shared-effects"><div class="shared-effect effect-heading"><span>ATTRIBUTE</span><span>TIER I VALUE</span><span>TIER II VALUE</span><span></span></div>{#each talent.effects as effect, index}<div class="shared-effect"><SearchableAssignment items={data.stats} mode="single" compact subjectLabel="ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={effect.statId}/><input type="number" step="any" bind:value={effect.valueI} placeholder="TIER I" required/><input type="number" step="any" bind:value={effect.valueII} placeholder="TIER II" required/><button type="button" aria-label={`Remove effect ${index + 1}`} onclick={() => removeEffect(index)}>×</button></div>{/each}</div>
|
||||
<MechanicProgramEditor
|
||||
attributes={data.stats}
|
||||
valueColumns={[{key:'tierI',variantKey:'I',label:'TIER I'},{key:'tierII',variantKey:'II',label:'TIER II'}]}
|
||||
idPrefix={`talent-${talent.id || 'new'}`}
|
||||
bind:rule={talent.conditionalRule}
|
||||
/>
|
||||
{#if form?.talentError}<p class="error">{form.talentError}</p>{/if}
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => talent = null}>CANCEL</button><button class="primary">SAVE TALENT →</button></div>
|
||||
</PendingForm>
|
||||
@@ -107,4 +116,4 @@
|
||||
{/if}
|
||||
|
||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><SortableColumnHeader label="TALENT" column="name" sort={data.sort}/><SortableColumnHeader label="CATEGORY" column="category" sort={data.sort}/><SortableColumnHeader label="TIER I EFFECT" column="tier1" sort={data.sort}/><SortableColumnHeader label="TIER II EFFECT" column="tier2" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row" class:pending-record={item.pending} class:registry-row-editing={String(talent?.id ?? '') === String(item.id)}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small>{#if cooldownLine(item.cooldown_seconds)}<small class="talent-cooldown">{cooldownLine(item.cooldown_seconds)}</small>{/if}</div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{tierDescription(item.tiers, 'II')}</p><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : 'ACTIVE'}</b>{#if item.pending}<span></span>{:else if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}<a class="row-action" href={`/isac/weapons?weapon=${item.ownerWeaponId}`}>EDIT WEAPON</a>{:else}<span></span>{/if}{:else}<button aria-expanded={String(talent?.id ?? '') === String(item.id)} aria-controls={`talent-editor-${item.id}`} onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{#if String(talent?.id ?? '') === String(item.id)}<RegistryInlineEditor id={`talent-editor-${item.id}`} label={`Edit ${item.name}`}>{@render talentEditor()}</RegistryInlineEditor>{/if}{/each}</div>
|
||||
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><SortableColumnHeader label="TALENT" column="name" sort={data.sort}/><SortableColumnHeader label="CATEGORY" column="category" sort={data.sort}/><SortableColumnHeader label="TIER I EFFECT" column="tier1" sort={data.sort}/><SortableColumnHeader label="TIER II EFFECT" column="tier2" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row" class:pending-record={item.pending} class:registry-row-editing={String(talent?.id ?? '') === String(item.id)}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small>{#if cooldownLine(item.cooldown_seconds)}<small class="talent-cooldown">{cooldownLine(item.cooldown_seconds)}</small>{/if}</div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{tierDescription(item.tiers, 'II')}</p><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : item.conditionalRule?.clauses?.length ? 'MODELED' : 'CONFIG REQUIRED'}</b>{#if item.pending}<span></span>{:else if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}<a class="row-action" href={`/isac/weapons?weapon=${item.ownerWeaponId}`}>EDIT WEAPON</a>{:else}<span></span>{/if}{:else}<button aria-expanded={String(talent?.id ?? '') === String(item.id)} aria-controls={`talent-editor-${item.id}`} onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{#if String(talent?.id ?? '') === String(item.id)}<RegistryInlineEditor id={`talent-editor-${item.id}`} label={`Edit ${item.name}`}>{@render talentEditor()}</RegistryInlineEditor>{/if}{/each}</div>
|
||||
|
||||
@@ -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<string, unknown>, 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<typeof parseConditionalRulePayload>) =>
|
||||
rule.clauses.flatMap((clause) => clause.outcomes);
|
||||
const descriptionOutcomes = (rule: ReturnType<typeof parseConditionalRulePayload>) =>
|
||||
mechanicOutcomes(rule).filter((outcome) => outcome.descriptionSlot)
|
||||
.sort((left, right) => Number(left.descriptionSlot) - Number(right.descriptionSlot));
|
||||
|
||||
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[] = [];
|
||||
const database = await getDatabase();
|
||||
let exoticConditionalRule = null;
|
||||
if (isExotic) {
|
||||
const rawRule = form.get('exoticConditionalRule');
|
||||
try {
|
||||
const parsed = JSON.parse(String(form.get('exoticEffects') ?? '[]')) as Array<Record<string, unknown>>;
|
||||
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 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(?,?,?,?,?)'))
|
||||
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 }) => {
|
||||
|
||||
@@ -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<any | null>(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 @@
|
||||
</div>
|
||||
{#if Number(editing.is_exotic)}
|
||||
<input type="hidden" name="exoticTalentId" value={editing.exoticTalent.id}/>
|
||||
<input type="hidden" name="exoticEffects" value={JSON.stringify(editing.exoticTalent.effects)}/>
|
||||
<input type="hidden" name="exoticConditionalRule" value={JSON.stringify(editing.exoticTalent.conditionalRule)}/>
|
||||
<section class="weapon-exotic-talent">
|
||||
<div class="selection-head"><div><span>DEDICATED EXOTIC TALENT</span><small>OWNED EXCLUSIVELY BY THIS WEAPON · TIER II VALUES REQUIRED</small></div><b>{editing.exoticTalent.id ? 'LINKED RECORD' : 'NEW RECORD'}</b></div>
|
||||
<div class="exotic-icon-identity"><IconUploadField currentUrl={editing.exoticTalent.iconUrl} label="EXOTIC TALENT ICON" resetToken={iconResetToken}/><div class="form-grid exotic-talent-identity"><label class="field">TALENT NAME<input name="exoticTalentName" bind:value={editing.exoticTalent.name} maxlength="80" required/></label><label class="field">SHARED DESCRIPTION TEMPLATE<textarea name="exoticDescriptionTemplate" bind:value={editing.exoticTalent.descriptionTemplate} placeholder={'Hits grant +{1} for {duration}…'} required></textarea><small>Use numbered expressions for effect values and named expressions for duration and stacks.</small></label></div></div>
|
||||
<div class="talent-mechanics-grid"><label class="field">COOLDOWN · SECONDS<input name="exoticCooldownSeconds" type="number" min="0.01" step="any" bind:value={editing.exoticTalent.cooldown_seconds} placeholder="NOT DEFINED"/></label><label class="field">DURATION · SECONDS<input name="exoticDurationSeconds" type="number" min="0.01" step="any" bind:value={editing.exoticTalent.duration_seconds} placeholder="NOT DEFINED"/></label><label class="field">MAX STACKS<input name="exoticMaxStacks" type="number" min="1" step="1" bind:value={editing.exoticTalent.max_stacks} placeholder="NOT DEFINED"/></label></div>
|
||||
<p class="talent-template-note">DESCRIPTION EXPRESSIONS · {'{duration}'} and {'{max_stacks}'} render the defined mechanics. Cooldown is displayed separately as a standardized Talent Cooldown line.</p>
|
||||
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{exoticPreview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{exoticPreview('II') || 'Description preview'}</p></div></div>
|
||||
<div class="weapon-exotic-grid">
|
||||
<div><div class="selection-head"><div><span>ACTIVATION TRIGGERS</span><small>SHARED BETWEEN BOTH TIERS</small></div><b>{editing.exoticTalent.triggerIds.length}</b></div><SearchableAssignment items={data.triggers} fieldName="exoticTriggerIds" bind:selected={editing.exoticTalent.triggerIds}/></div>
|
||||
<div><div class="selection-head"><div><span>ATTRIBUTE EFFECTS</span><small>ONE ROW PER TEMPLATE PLACEHOLDER</small></div><button type="button" class="row-action" onclick={addExoticEffect} disabled={editing.exoticTalent.effects.length >= 12}>ADD EFFECT +</button></div><div class="exotic-effect-list">{#each editing.exoticTalent.effects as effect: any, index}<div class="exotic-effect-row"><strong>{index + 1}</strong><SearchableAssignment items={data.stats} mode="single" compact subjectLabel="ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={effect.statId}/><input type="number" step="any" bind:value={effect.valueI} placeholder="TIER I" required/><input type="number" step="any" bind:value={effect.valueII} placeholder="TIER II" required/><button type="button" aria-label={`Remove exotic effect ${index + 1}`} onclick={() => removeExoticEffect(index)} disabled={editing.exoticTalent.effects.length === 1}>×</button></div>{/each}</div></div>
|
||||
</div>
|
||||
<MechanicProgramEditor
|
||||
attributes={data.stats}
|
||||
valueColumns={[{key:'tierI',variantKey:'I',label:'TIER I'},{key:'tierII',variantKey:'II',label:'TIER II'}]}
|
||||
idPrefix={`weapon-exotic-${editing.exoticTalent.id || 'new'}`}
|
||||
bind:rule={editing.exoticTalent.conditionalRule}
|
||||
/>
|
||||
</section>
|
||||
{/if}
|
||||
<div class="selection-head"><div><span>FIXED WEAPON ATTRIBUTES</span><small>UNCHANGED ACROSS TIERS</small></div><b>BASE HANDLING</b></div>
|
||||
|
||||
Reference in New Issue
Block a user