feat: model specialization combat effects
This commit is contained in:
+140
-1
@@ -242,6 +242,7 @@ INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-metric
|
||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-system-notifications-manage','system.notifications.manage','View and edit automated ISAC system notification messages');
|
||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-images-manage','images.manage','View, upload, rename, tag, and categorize managed image assets');
|
||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-avatars-review','avatars.review','Review and remove custom user avatars');
|
||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-calculation-publish','calculation.publish','Activate combat calculation revisions used by every loadout');
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-isac-access');
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-data-manage');
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-data-view');
|
||||
@@ -277,7 +278,7 @@ INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moder
|
||||
CREATE TABLE IF NOT EXISTS oauth_states (state_hash TEXT PRIMARY KEY, provider TEXT NOT NULL, mode TEXT NOT NULL, user_id TEXT REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS data_change_proposals (
|
||||
id TEXT PRIMARY KEY,
|
||||
module TEXT NOT NULL CHECK(module IN ('configuration','attributes','talents','brands','gear','weapons','weaponmods','gearmods','protocols','skillchips','specializations')),
|
||||
module TEXT NOT NULL CHECK(module IN ('configuration','calculations','attributes','talents','brands','gear','weapons','weaponmods','gearmods','protocols','skillchips','specializations')),
|
||||
action TEXT NOT NULL,
|
||||
entity_id TEXT,
|
||||
draft_entity_id TEXT,
|
||||
@@ -922,6 +923,144 @@ CREATE TABLE IF NOT EXISTS specialization_tree_rank_costs (
|
||||
PRIMARY KEY(node_id,rank,resource_id),
|
||||
FOREIGN KEY(node_id,rank) REFERENCES specialization_tree_node_ranks(node_id,rank) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS specialization_combat_effects (
|
||||
id TEXT PRIMARY KEY,
|
||||
specialization_id TEXT NOT NULL REFERENCES specializations(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description_template TEXT NOT NULL,
|
||||
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(specialization_id,name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS specialization_combat_effects_specialization_idx
|
||||
ON specialization_combat_effects(specialization_id,active,name);
|
||||
CREATE TABLE IF NOT EXISTS specialization_tree_rank_combat_effects (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL,
|
||||
rank INTEGER NOT NULL,
|
||||
combat_effect_id TEXT NOT NULL REFERENCES specialization_combat_effects(id),
|
||||
effect_order INTEGER NOT NULL CHECK(effect_order>=1),
|
||||
trigger_type TEXT NOT NULL CHECK(trigger_type IN ('enemy_hits_within_window')),
|
||||
trigger_count INTEGER NOT NULL CHECK(trigger_count>0),
|
||||
trigger_window_seconds REAL NOT NULL CHECK(trigger_window_seconds>0),
|
||||
duration_seconds REAL NOT NULL CHECK(duration_seconds>0),
|
||||
max_stacks INTEGER NOT NULL CHECK(max_stacks>0),
|
||||
stacks_per_trigger INTEGER NOT NULL DEFAULT 1 CHECK(stacks_per_trigger>0),
|
||||
counter_policy TEXT NOT NULL DEFAULT 'reset_on_proc' CHECK(counter_policy IN ('reset_on_proc')),
|
||||
refresh_policy TEXT NOT NULL DEFAULT 'refresh_on_stack_gain' CHECK(refresh_policy IN ('refresh_on_stack_gain')),
|
||||
expiry_policy TEXT NOT NULL DEFAULT 'clear_all' CHECK(expiry_policy IN ('clear_all')),
|
||||
FOREIGN KEY(node_id,rank) REFERENCES specialization_tree_node_ranks(node_id,rank) ON DELETE CASCADE,
|
||||
UNIQUE(node_id,rank,effect_order)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS specialization_tree_rank_combat_effects_effect_idx
|
||||
ON specialization_tree_rank_combat_effects(combat_effect_id);
|
||||
CREATE TABLE IF NOT EXISTS specialization_tree_rank_combat_modifiers (
|
||||
id TEXT PRIMARY KEY,
|
||||
rank_combat_effect_id TEXT NOT NULL REFERENCES specialization_tree_rank_combat_effects(id) ON DELETE CASCADE,
|
||||
modifier_order INTEGER NOT NULL CHECK(modifier_order>=1),
|
||||
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
|
||||
value_per_stack REAL NOT NULL,
|
||||
unit TEXT,
|
||||
UNIQUE(rank_combat_effect_id,modifier_order)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS calculation_revisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
version INTEGER NOT NULL UNIQUE CHECK(version>0),
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','active','retired')),
|
||||
notes TEXT,
|
||||
based_on_revision_id TEXT REFERENCES calculation_revisions(id),
|
||||
created_by TEXT REFERENCES users(id),
|
||||
created_at TEXT NOT NULL,
|
||||
published_by TEXT REFERENCES users(id),
|
||||
published_at TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS calculation_revisions_active_idx
|
||||
ON calculation_revisions(status) WHERE status='active';
|
||||
CREATE TABLE IF NOT EXISTS calculation_stages (
|
||||
id TEXT PRIMARY KEY,
|
||||
revision_id TEXT NOT NULL REFERENCES calculation_revisions(id) ON DELETE CASCADE,
|
||||
stage_key TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
operator TEXT NOT NULL CHECK(operator IN (
|
||||
'base_damage','flat_add','additive_percent','multiplicative_percent',
|
||||
'expected_critical','expected_headshot','rate_of_fire','magazine_size',
|
||||
'reload_speed','firing_cycle'
|
||||
)),
|
||||
display_order INTEGER NOT NULL CHECK(display_order>=1),
|
||||
locked_position TEXT CHECK(locked_position IS NULL OR locked_position IN ('first','last')),
|
||||
UNIQUE(revision_id,stage_key),
|
||||
UNIQUE(revision_id,display_order)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS 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,
|
||||
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,
|
||||
weapon_category TEXT NOT NULL DEFAULT '*',
|
||||
UNIQUE(revision_id,stat_attribute_id,stage_key,weapon_category),
|
||||
FOREIGN KEY(revision_id,stage_key) REFERENCES calculation_stages(revision_id,stage_key) ON DELETE CASCADE,
|
||||
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,
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','ready')),
|
||||
loadout_snapshot_json TEXT NOT NULL,
|
||||
selected_weapon INTEGER NOT NULL DEFAULT 0 CHECK(selected_weapon IN (0,1)),
|
||||
game_version TEXT,
|
||||
observed_at TEXT,
|
||||
notes TEXT,
|
||||
body_hit REAL,
|
||||
critical_body_hit REAL,
|
||||
headshot REAL,
|
||||
critical_headshot REAL,
|
||||
magazine_seconds REAL,
|
||||
reload_seconds REAL,
|
||||
encounter_dps REAL,
|
||||
predicted_json TEXT,
|
||||
calculation_revision_id TEXT REFERENCES calculation_revisions(id),
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK(body_hit IS NULL OR body_hit>=0),
|
||||
CHECK(critical_body_hit IS NULL OR critical_body_hit>=0),
|
||||
CHECK(headshot IS NULL OR headshot>=0),
|
||||
CHECK(critical_headshot IS NULL OR critical_headshot>=0),
|
||||
CHECK(magazine_seconds IS NULL OR magazine_seconds>0),
|
||||
CHECK(reload_seconds IS NULL OR reload_seconds>0),
|
||||
CHECK(encounter_dps IS NULL OR encounter_dps>=0)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS calculation_calibration_status_idx
|
||||
ON calculation_calibration_cases(status,updated_at DESC);
|
||||
INSERT OR IGNORE INTO calculation_revisions(id,version,name,status,notes,created_at,published_at)
|
||||
VALUES('calculation-revision-1',1,'Initial combat model','active','Typed baseline combat stages','2026-07-26T00:00:00.000Z','2026-07-26T00:00:00.000Z');
|
||||
INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator,display_order,locked_position)
|
||||
VALUES('calculation-stage-base-1','calculation-revision-1','base_damage','Base damage','base_damage',1,'first');
|
||||
INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator,display_order)
|
||||
VALUES('calculation-stage-flat-1','calculation-revision-1','flat_damage','Flat damage','flat_add',2);
|
||||
INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator,display_order)
|
||||
VALUES('calculation-stage-additive-1','calculation-revision-1','additive_damage','Additive damage','additive_percent',3);
|
||||
INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator,display_order)
|
||||
VALUES('calculation-stage-multiplicative-1','calculation-revision-1','multiplicative_damage','Multiplicative damage','multiplicative_percent',4);
|
||||
INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator,display_order)
|
||||
VALUES('calculation-stage-critical-chance-1','calculation-revision-1','critical_chance','Critical chance','expected_critical',5);
|
||||
INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator,display_order)
|
||||
VALUES('calculation-stage-critical-1','calculation-revision-1','critical_damage','Critical damage','expected_critical',6);
|
||||
INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator,display_order)
|
||||
VALUES('calculation-stage-headshot-chance-1','calculation-revision-1','headshot_chance','Headshot chance','expected_headshot',7);
|
||||
INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator,display_order)
|
||||
VALUES('calculation-stage-headshot-1','calculation-revision-1','headshot_damage','Headshot damage','expected_headshot',8);
|
||||
INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator,display_order,locked_position)
|
||||
VALUES('calculation-stage-cycle-1','calculation-revision-1','firing_cycle','Firing cycle','firing_cycle',9,'last');
|
||||
CREATE TABLE IF NOT EXISTS game_data_icons (
|
||||
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node')),
|
||||
entity_id TEXT NOT NULL,
|
||||
|
||||
+74
-1
@@ -196,8 +196,10 @@ The canonical tokens live in `src/app.css` under `:root`.
|
||||
| Page background | `--shd-bg` | `#0a0c0d` |
|
||||
| Base surface | `--shd-surface` | `#111415` |
|
||||
| Raised surface | `--shd-surface-raised` | `#141718` |
|
||||
| Modeled-data surface | `--shd-model-surface` | `#0d1213` |
|
||||
| Subordinate border | `--shd-border-soft` | `#272c2d` |
|
||||
| Primary border | `--shd-border` | `#303536` |
|
||||
| Modeled-data border | `--shd-model-border` | `#3b4648` |
|
||||
| Interactive boundary | `--shd-border-strong` | `#424849` |
|
||||
| Main text | `--shd-text` | `#ecebe5` |
|
||||
| Muted text | `--shd-text-muted` | `#858b8d` |
|
||||
@@ -206,6 +208,7 @@ The canonical tokens live in `src/app.css` under `:root`.
|
||||
| Supporting copy floor | `--shd-type-supporting` | `14px` |
|
||||
| Ordinary body floor | `--shd-type-body` | `16px` |
|
||||
| Adaptive display scale | `--shd-display-scale` | `1` by default; stepped on high-resolution desktops |
|
||||
| Monospaced data | `--shd-font-mono` | Cascadia Mono with system monospace fallbacks |
|
||||
|
||||
### High-resolution display scaling
|
||||
|
||||
@@ -1605,7 +1608,9 @@ Pair the short state label with a complete consequence sentence. Contributors
|
||||
must not have to infer this behavior from their role name, data-mode color, or a
|
||||
success message shown after saving. Read-only operators do not see a mutation
|
||||
statement because their mutation controls are absent. At narrow widths the
|
||||
message becomes its own compact row above the module toolbar.
|
||||
message becomes its own compact row above the module toolbar. A toolbar with
|
||||
multiple actions must reserve the complete action group width in the inline
|
||||
desktop strip so the consequence sentence never runs beneath a button.
|
||||
|
||||
### Public database index
|
||||
|
||||
@@ -1692,3 +1697,71 @@ components use the shared blue quality treatment and may be combined in any
|
||||
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
|
||||
|
||||
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 `--shd-model-surface` and `--shd-model-border` for conditional mechanics,
|
||||
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.
|
||||
|
||||
The loadout builder gives each unlocked conditional effect three calculation
|
||||
modes:
|
||||
|
||||
- `OFF` excludes the effect.
|
||||
- `PEAK` applies its configured maximum stacks.
|
||||
- `ENCOUNTER` models stack buildup and expiry from weapon RPM, magazine size,
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Specialization loadout composer
|
||||
|
||||
The builder places Specialization focus selection after equipment
|
||||
configuration. The focus identity combines Specialization artwork and the
|
||||
primary Attribute mark, followed by three ability panels. Each ability panel
|
||||
contains three fixed Skill Chip positions. A chip picker shows definitions
|
||||
assigned to that ability and excludes definitions assigned elsewhere.
|
||||
|
||||
The focus tree groups nodes by row and uses segmented rank controls. Disabled
|
||||
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. Version 1 loadouts keep their three legacy chips in an
|
||||
unassigned recovery list until the user places them under compatible abilities.
|
||||
|
||||
### Calculation revision controls
|
||||
|
||||
Combat rules use immutable active and retired revisions. ISAC editors may
|
||||
change draft revisions, ordered stages, and Attribute profiles. Activation
|
||||
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.
|
||||
|
||||
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
|
||||
to an authorized operator. Use monospaced text for raw loadout and prediction
|
||||
snapshots; labels, findings, and errors stay in the standard operational
|
||||
typeface.
|
||||
|
||||
@@ -290,6 +290,62 @@ 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.
|
||||
|
||||
# Specialization combat effects 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.
|
||||
|
||||
`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.
|
||||
|
||||
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. Version 1 migration keeps
|
||||
the three old chips in `specialization.unassignedChips`; the builder requires
|
||||
the user to place them under compatible abilities before it reports a complete
|
||||
calculation.
|
||||
|
||||
# Versioned combat calculations
|
||||
|
||||
`calculation_revisions` supplies one active rule set. Draft revisions can
|
||||
change, 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.
|
||||
|
||||
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.
|
||||
|
||||
The engine returns `INSUFFICIENT_DATA` with missing requirement keys when a
|
||||
calculation lacks required inputs or an Attribute binding. Callers must render
|
||||
that state instead of a partial result. Saved and shared builds load the current
|
||||
active revision for each request.
|
||||
|
||||
`calculation_calibration_cases` stores a versioned loadout snapshot, observed
|
||||
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.
|
||||
|
||||
Rule activation requires `calculation.publish` and an explicit 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.
|
||||
|
||||
# Game-data icon storage
|
||||
|
||||
Talents, brand sets, OS Protocols, and Skill Chips reference uploaded artwork
|
||||
|
||||
Generated
+349
-1
@@ -27,7 +27,8 @@
|
||||
"svelte": "^5.56.1",
|
||||
"svelte-check": "^4.6.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@dotenvx/dotenvx": {
|
||||
@@ -1962,6 +1963,17 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
@@ -1969,6 +1981,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -2010,6 +2029,129 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
|
||||
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
|
||||
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.10",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker/node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
|
||||
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
|
||||
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.10",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
|
||||
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
|
||||
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
|
||||
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.17.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||
@@ -2063,6 +2205,16 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
@@ -2073,6 +2225,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
@@ -2106,6 +2268,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||
@@ -2183,6 +2352,13 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
|
||||
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esm-env": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
||||
@@ -2215,6 +2391,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -2708,6 +2894,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -2998,6 +3191,13 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sirv": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
||||
@@ -3023,6 +3223,20 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
|
||||
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/supports-preserve-symlinks-flag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
@@ -3089,6 +3303,23 @@
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
|
||||
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -3106,6 +3337,16 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/totalist": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||
@@ -3259,6 +3500,96 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
|
||||
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.10",
|
||||
"@vitest/mocker": "4.1.10",
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/runner": "4.1.10",
|
||||
"@vitest/snapshot": "4.1.10",
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.10",
|
||||
"@vitest/browser-preview": "4.1.10",
|
||||
"@vitest/browser-webdriverio": "4.1.10",
|
||||
"@vitest/coverage-istanbul": "4.1.10",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"@vitest/ui": "4.1.10",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -3274,6 +3605,23 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-spinner": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.2.2.tgz",
|
||||
|
||||
+3
-1
@@ -49,6 +49,7 @@
|
||||
"env:stage": "node scripts/env-files.mjs stage",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -61,7 +62,8 @@
|
||||
"svelte": "^5.56.1",
|
||||
"svelte-check": "^4.6.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/barlow": "^5.3.0",
|
||||
|
||||
@@ -219,6 +219,40 @@ if (typeof specializationProposalDefinition?.sql === 'string' && !specialization
|
||||
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL');
|
||||
}
|
||||
|
||||
const calculationProposalDefinition = await (await database.prepare(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'"
|
||||
)).get();
|
||||
if (typeof calculationProposalDefinition?.sql === 'string' && !calculationProposalDefinition.sql.includes("'calculations'")) {
|
||||
console.log('Adding Combat Calculations to moderated data modules.');
|
||||
await database.exec('PRAGMA foreign_keys=OFF');
|
||||
await database.exec(`CREATE TABLE data_change_proposals_calculations (
|
||||
id TEXT PRIMARY KEY,
|
||||
module TEXT NOT NULL CHECK(module IN ('configuration','calculations','attributes','talents','brands','gear','weapons','weaponmods','gearmods','protocols','skillchips','specializations')),
|
||||
action TEXT NOT NULL,
|
||||
entity_id TEXT,
|
||||
draft_entity_id TEXT,
|
||||
applied_entity_id TEXT,
|
||||
summary TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
|
||||
proposed_by TEXT NOT NULL REFERENCES users(id),
|
||||
reviewed_by TEXT REFERENCES users(id),
|
||||
review_note TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
reviewed_at TEXT,
|
||||
applied_at TEXT
|
||||
)`);
|
||||
await database.exec(`INSERT INTO data_change_proposals_calculations
|
||||
SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
|
||||
FROM data_change_proposals`);
|
||||
await database.exec('DROP TABLE data_change_proposals');
|
||||
await database.exec('ALTER TABLE data_change_proposals_calculations RENAME TO data_change_proposals');
|
||||
await database.exec('PRAGMA foreign_keys=ON');
|
||||
await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)');
|
||||
await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)');
|
||||
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL');
|
||||
}
|
||||
|
||||
const iconDefinition = await (await database.prepare(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'"
|
||||
)).get();
|
||||
|
||||
@@ -52,6 +52,7 @@ export const ISAC_DATA_TABLES = [
|
||||
'specialization_rank_cost_profiles',
|
||||
'upgrade_resources',
|
||||
'specializations',
|
||||
'specialization_combat_effects',
|
||||
'specialization_focuses',
|
||||
'specialization_abilities',
|
||||
'specialization_ability_effects',
|
||||
@@ -60,6 +61,12 @@ export const ISAC_DATA_TABLES = [
|
||||
'specialization_tree_node_ranks',
|
||||
'specialization_tree_rank_effects',
|
||||
'specialization_tree_rank_costs',
|
||||
'specialization_tree_rank_combat_effects',
|
||||
'specialization_tree_rank_combat_modifiers',
|
||||
'calculation_revisions',
|
||||
'calculation_stages',
|
||||
'attribute_calculation_profiles',
|
||||
'calculation_calibration_cases',
|
||||
'game_data_icons',
|
||||
'managed_images'
|
||||
];
|
||||
|
||||
+12
@@ -22,6 +22,7 @@
|
||||
--shd-info:#70b9d6;
|
||||
--shd-font-sans:"Barlow","Arial Narrow",Arial,Helvetica,sans-serif;
|
||||
--shd-font-editorial:"Source Serif 4",Georgia,serif;
|
||||
--shd-font-mono:"Cascadia Mono","SFMono-Regular",Consolas,monospace;
|
||||
--quality-standard:#69b66f;
|
||||
--quality-specialized:#4f9ed8;
|
||||
--quality-superior:#9b68d4;
|
||||
@@ -36,6 +37,8 @@
|
||||
--shd-header-height:76px;
|
||||
--shd-display-scale:1;
|
||||
--shd-transition:transform .12s ease,filter .18s ease,opacity .18s ease,box-shadow .18s ease;
|
||||
--shd-model-surface:#0d1213;
|
||||
--shd-model-border:#3b4648;
|
||||
}
|
||||
*{box-sizing:border-box} html{scroll-behavior:smooth} body{min-height:calc(100vh / var(--shd-display-scale));display:flex;flex-direction:column;margin:0;background:var(--shd-bg);color:var(--shd-text);font-family:var(--shd-font-sans);font-optical-sizing:auto;font-synthesis:none} button,input{font:inherit} a,a:hover,a:focus,a:focus-visible,a:active,a:visited{text-decoration:none}
|
||||
::selection{background:var(--shd-selection-bg);color:var(--shd-selection-text)}input,textarea{caret-color:var(--shd-orange)}input[type=checkbox],input[type=radio],input[type=range]{accent-color:var(--shd-orange)}:where(input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file]):not([type=hidden]),select,textarea):focus{border-color:var(--shd-focus-ring);outline:none}:where(input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file]):not([type=hidden]),select,textarea):focus-visible{outline:1px solid var(--shd-focus-ring);outline-offset:1px}:where(input,textarea):-webkit-autofill,:where(input,textarea):-webkit-autofill:hover,:where(input,textarea):-webkit-autofill:focus{-webkit-text-fill-color:var(--shd-text);caret-color:var(--shd-orange);box-shadow:inset 0 0 0 1000px #15130e;transition:background-color 9999s ease-out}
|
||||
@@ -246,6 +249,13 @@
|
||||
line-height:1.25;
|
||||
letter-spacing:.08em;
|
||||
}
|
||||
|
||||
/* 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}
|
||||
.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}}
|
||||
.community-index-hero>div:last-child .primary{
|
||||
font-size:var(--shd-type-telemetry);
|
||||
}
|
||||
@@ -420,6 +430,7 @@
|
||||
}
|
||||
.mutation-consequence span{color:var(--shd-text-muted)}
|
||||
.mutation-consequence.direct b{border-color:var(--shd-success);color:var(--shd-success)}
|
||||
.admin-content:has(.calculation-actions) .mutation-policy-strip{padding-right:380px}
|
||||
@media(max-width:1100px){
|
||||
.mutation-policy-strip{
|
||||
justify-content:flex-start;
|
||||
@@ -427,6 +438,7 @@
|
||||
padding:8px 0;
|
||||
border-bottom:1px solid var(--shd-border);
|
||||
}
|
||||
.admin-content:has(.calculation-actions) .mutation-policy-strip{padding-right:0}
|
||||
}
|
||||
@media(max-width:600px){
|
||||
.mutation-consequence{align-items:flex-start;flex-direction:column;gap:5px}
|
||||
|
||||
@@ -14,6 +14,9 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
const module = match?.[1] as DataModule | undefined;
|
||||
const action = event.url.search.startsWith('?/') ? event.url.search.slice(2) : '';
|
||||
if (module && DATA_MODULES.includes(module) && action) {
|
||||
// Publishing calculation rules is never a contributor proposal. The
|
||||
// action has its own dedicated permission and confirmation contract.
|
||||
if (module === 'calculations' && action === 'publishRevision') return resolve(event);
|
||||
const user = await getSessionUser(event.cookies);
|
||||
const access = await shouldModerateDataMutation(user);
|
||||
if (!access.canManage && access.canPropose && user) {
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
import {
|
||||
calculateCombat,
|
||||
type AttributeBinding,
|
||||
type CalculationStage
|
||||
} from '$lib/calculations/combat';
|
||||
import { gearSlots, scaled, type LoadoutDraft } from '$lib/builder/loadout';
|
||||
import type { WeaponCalculationResult } from '$lib/components/builder/WeaponCalculationResult.svelte';
|
||||
|
||||
type Catalog = {
|
||||
attributes: any[];
|
||||
gear: any[];
|
||||
gearAugments: any[];
|
||||
brands: any[];
|
||||
weapons: any[];
|
||||
talents: any[];
|
||||
chipSets: any[];
|
||||
protocols: any[];
|
||||
specializations: any[];
|
||||
calculation: { revision: any | null; stages: any[]; profiles: any[] };
|
||||
};
|
||||
|
||||
export type LoadoutSummary = {
|
||||
stats: Array<{ id: string; value: number; sourceCount: number }>;
|
||||
activeBonuses: any[];
|
||||
equippedTalents: any[];
|
||||
};
|
||||
|
||||
const coreKeys = ['firepower', 'toughness', 'engineering'] as const;
|
||||
|
||||
function selectedFocus(draft: LoadoutDraft, catalog: Catalog) {
|
||||
return catalog.specializations
|
||||
.flatMap((specialization) => specialization.focuses.map((focus: any) => ({ ...focus, specialization })))
|
||||
.find((focus: any) => focus.id === draft.specialization.focusId);
|
||||
}
|
||||
|
||||
function selectedNodeRanks(draft: LoadoutDraft, catalog: Catalog) {
|
||||
const focus = selectedFocus(draft, catalog);
|
||||
return focus?.nodes.flatMap((node: any) => {
|
||||
const selectedRank = Number(draft.specialization.nodeRanks[node.id] ?? 0);
|
||||
return selectedRank
|
||||
? [{ node, rank: node.ranks.find((entry: any) => Number(entry.rank) === selectedRank) }]
|
||||
: [];
|
||||
}) ?? [];
|
||||
}
|
||||
|
||||
function attribute(catalog: Catalog, id: string) {
|
||||
return catalog.attributes.find((item) => item.id === id);
|
||||
}
|
||||
|
||||
function unitValue(catalog: Catalog, value: unknown, statId: string) {
|
||||
const numeric = Number(value);
|
||||
const rendered = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/\.?0+$/, '');
|
||||
const unit = attribute(catalog, statId)?.unit ?? '';
|
||||
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
|
||||
}
|
||||
|
||||
function talentTier(catalog: Catalog, talentId: string, tier: number) {
|
||||
const item = catalog.talents.find((entry) => entry.id === talentId);
|
||||
return item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ?? item?.tiers[0];
|
||||
}
|
||||
|
||||
function talentDescription(catalog: Catalog, talentId: string, tier: number) {
|
||||
const item = catalog.talents.find((entry) => entry.id === talentId);
|
||||
const configured = talentTier(catalog, talentId, tier);
|
||||
if (!item || !configured) return '';
|
||||
const template = String(item.template || item.name);
|
||||
return template.replace(/\{(\d+)\}/g, (placeholder, raw) => {
|
||||
const effect = configured.effects[Number(raw) - 1];
|
||||
return effect ? unitValue(catalog, effect.value, effect.stat_attribute_id) : placeholder;
|
||||
});
|
||||
}
|
||||
|
||||
export function summarizeLoadout(draft: LoadoutDraft, catalog: Catalog): LoadoutSummary {
|
||||
const values = new Map<string, number>();
|
||||
const sources = new Map<string, Set<string>>();
|
||||
const add = (statId: string, value: unknown, source: string) => {
|
||||
if (!statId || !Number.isFinite(Number(value))) return;
|
||||
const stat = attribute(catalog, statId);
|
||||
const current = values.get(statId) ?? 0;
|
||||
const incoming = Number(value);
|
||||
const combined = stat?.calculation_type === 'Multiplicative' && stat?.unit === '%'
|
||||
? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100
|
||||
: current + incoming;
|
||||
values.set(
|
||||
statId,
|
||||
stat?.cap_value === null || stat?.cap_value === undefined
|
||||
? combined
|
||||
: Math.min(combined, Number(stat.cap_value))
|
||||
);
|
||||
if (!sources.has(statId)) sources.set(statId, new Set());
|
||||
sources.get(statId)!.add(source);
|
||||
};
|
||||
const byName = (name: string) =>
|
||||
catalog.attributes.find((item) => item.name.toLocaleLowerCase() === name.toLocaleLowerCase())?.id ?? '';
|
||||
const brandCounts = new Map<string, number>();
|
||||
const activeBonuses: any[] = [];
|
||||
const equippedTalents: any[] = [];
|
||||
|
||||
for (const slot of gearSlots) {
|
||||
const selection = draft.gear[slot];
|
||||
const item = catalog.gear.find((entry) => entry.id === selection.itemId);
|
||||
if (!item) continue;
|
||||
const enabledAugments = catalog.gearAugments.filter(
|
||||
(entry) => entry.gear_slot === slot && selection.augmentSlots.includes(Number(entry.augment_slot))
|
||||
);
|
||||
const basePercentage = enabledAugments
|
||||
.filter((entry) => entry.effect_type === 'Base Percentage')
|
||||
.reduce((total, entry) => total + Number(entry.value), 0);
|
||||
add(byName('Health'), selection.health * (1 + basePercentage / 100), `${slot} base`);
|
||||
if (item.base_armor_min !== null) add(byName('Armor'), selection.armor * (1 + basePercentage / 100), `${slot} base`);
|
||||
for (const roll of selection.rolls) add(roll.statId, roll.value, `${slot} roll`);
|
||||
for (const key of coreKeys) add(byName(key), selection[key], `${slot} core`);
|
||||
if (selection.brandId) brandCounts.set(selection.brandId, (brandCounts.get(selection.brandId) ?? 0) + 1);
|
||||
for (const augment of enabledAugments.filter((entry) => entry.effect_type === 'Custom')) {
|
||||
add(augment.stat_attribute_id, augment.value, `${slot} augment ${augment.augment_slot}`);
|
||||
}
|
||||
if (selection.talentId) {
|
||||
equippedTalents.push({
|
||||
key: `gear:${slot}:${selection.talentId}`,
|
||||
id: selection.talentId,
|
||||
tier: Number(item.tier),
|
||||
source: slot,
|
||||
description: talentDescription(catalog, selection.talentId, Number(item.tier))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [brandId, count] of brandCounts) {
|
||||
const set = catalog.brands.find((entry) => entry.id === brandId);
|
||||
for (const effect of set?.effects.filter((entry: any) => Number(entry.pieces_required) <= count) ?? []) {
|
||||
add(effect.stat_attribute_id, effect.value, `${set.name} ${effect.pieces_required}-piece`);
|
||||
activeBonuses.push({
|
||||
name: set.name,
|
||||
pieces: effect.pieces_required,
|
||||
count,
|
||||
statId: effect.stat_attribute_id,
|
||||
value: effect.value
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
const selection = draft.weapons[index];
|
||||
const item = catalog.weapons.find((entry) => entry.id === selection.itemId);
|
||||
if (!item) continue;
|
||||
for (const name of ['Firepower', 'Toughness', 'Engineering'] as const) {
|
||||
add(byName(name), selection[name.toLowerCase() as 'firepower'], `${index ? 'Secondary' : 'Primary'} weapon`);
|
||||
}
|
||||
for (const bonus of item.baseBonuses ?? []) add(bonus.stat_attribute_id, bonus.value, `${item.name} base bonus`);
|
||||
for (const roll of selection.rolls) add(roll.statId, roll.value, `${index ? 'Secondary' : 'Primary'} weapon roll`);
|
||||
for (const talentId of [selection.talent1Id, selection.talent2Id, item.exotic_talent_id].filter(Boolean)) {
|
||||
equippedTalents.push({
|
||||
key: `weapon:${index}:${talentId}`,
|
||||
id: talentId,
|
||||
tier: selection.tier,
|
||||
source: index ? 'Secondary weapon' : 'Primary weapon',
|
||||
description: talentDescription(catalog, talentId, selection.tier)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const chipGroups = new Map<string, { count: number; highEnd: number; item: any }>();
|
||||
for (let index = 0; index < 9; index += 1) {
|
||||
const selection = draft.chips[index];
|
||||
const item = catalog.chipSets.find((entry) => entry.id === selection.setId);
|
||||
if (!item) continue;
|
||||
add(selection.primary.statId, selection.primary.value, `Skill Chip ${index + 1} primary`);
|
||||
for (const roll of selection.secondary) add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
|
||||
const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item };
|
||||
group.count += 1;
|
||||
if (selection.quality === 'High-end') group.highEnd += 1;
|
||||
chipGroups.set(item.id, group);
|
||||
}
|
||||
for (const group of chipGroups.values()) {
|
||||
if (group.count >= 2 && group.item.twoPiece) {
|
||||
const value = scaled(group.item.twoPiece.min_value, group.item.twoPiece.max_value, group.highEnd, 3);
|
||||
add(group.item.twoPiece.stat_attribute_id, value, `${group.item.name} 2-piece`);
|
||||
activeBonuses.push({
|
||||
name: group.item.name,
|
||||
pieces: 2,
|
||||
count: group.count,
|
||||
statId: group.item.twoPiece.stat_attribute_id,
|
||||
value
|
||||
});
|
||||
}
|
||||
if (group.count === 3 && group.item.talent) {
|
||||
equippedTalents.push({
|
||||
key: `chips:${group.item.id}`,
|
||||
id: '',
|
||||
custom: group.item.talent,
|
||||
tier: group.highEnd,
|
||||
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];
|
||||
return effect
|
||||
? unitValue(catalog, scaled(effect.min_value, effect.max_value, group.highEnd, 3), effect.stat_attribute_id)
|
||||
: placeholder;
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const { node, rank } of selectedNodeRanks(draft, catalog)) {
|
||||
for (const effect of rank?.effects ?? []) {
|
||||
if (effect.effect_type === 'Attribute') add(effect.stat_attribute_id, effect.value, `${node.name} rank ${rank.rank}`);
|
||||
}
|
||||
}
|
||||
|
||||
const protocol = catalog.protocols.find((entry) => entry.id === draft.protocol.itemId);
|
||||
if (protocol) {
|
||||
if (protocol.primaryEffect) {
|
||||
add(
|
||||
protocol.primaryEffect.stat_attribute_id,
|
||||
Number(protocol.primaryEffect.base_value) * (1 + (draft.protocol.level - 1) / 49),
|
||||
`${protocol.name} primary`
|
||||
);
|
||||
}
|
||||
for (const effect of protocol.secondaryEffects ?? []) {
|
||||
add(effect.stat_attribute_id, effect.value, `${protocol.name} secondary`);
|
||||
}
|
||||
if (protocol.talent) {
|
||||
equippedTalents.push({
|
||||
key: `protocol:${protocol.id}`,
|
||||
id: '',
|
||||
custom: protocol.talent,
|
||||
tier: draft.protocol.talentUpgrades,
|
||||
source: 'OS Protocol',
|
||||
description: protocol.talent.description_template.replace(/\{(\d+)\}/g, (placeholder: string, raw: string) => {
|
||||
const effect = protocol.talent.effects[Number(raw) - 1];
|
||||
return effect
|
||||
? unitValue(
|
||||
catalog,
|
||||
Number(effect.base_value) * (1 + draft.protocol.talentUpgrades / 5),
|
||||
effect.stat_attribute_id
|
||||
)
|
||||
: placeholder;
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 }))
|
||||
.sort((left, right) =>
|
||||
(attribute(catalog, left.id)?.category ?? '').localeCompare(attribute(catalog, right.id)?.category ?? '')
|
||||
|| (attribute(catalog, left.id)?.name ?? '').localeCompare(attribute(catalog, right.id)?.name ?? '')
|
||||
),
|
||||
activeBonuses,
|
||||
equippedTalents
|
||||
};
|
||||
}
|
||||
|
||||
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)) {
|
||||
return stageKey as CalculationStage;
|
||||
}
|
||||
const stage = catalog.calculation.stages.find((item) => item.stage_key === stageKey);
|
||||
if (!stage) return null;
|
||||
if (stage.operator === 'flat_add') return 'flat_damage';
|
||||
if (stage.operator === 'additive_percent') return 'weapon_damage';
|
||||
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';
|
||||
return null;
|
||||
}
|
||||
|
||||
function calculationBindings(catalog: Catalog, weapon: any): AttributeBinding[] {
|
||||
return catalog.calculation.profiles
|
||||
.filter((item) => item.weapon_category === '*' || item.weapon_category === weapon?.category)
|
||||
.flatMap((profile) => {
|
||||
const stage = calculationStage(catalog, String(profile.stage_key));
|
||||
return stage
|
||||
? [{
|
||||
attributeId: String(profile.stat_attribute_id),
|
||||
stage,
|
||||
operator: 'add' as const,
|
||||
weight: Number(profile.weight),
|
||||
softCap: profile.soft_cap,
|
||||
hardCap: profile.hard_cap,
|
||||
curveType: profile.curve_type,
|
||||
curveParameter: profile.curve_parameter
|
||||
}]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
export function evaluateLoadoutCombat(
|
||||
draft: LoadoutDraft,
|
||||
catalog: Catalog,
|
||||
summary = summarizeLoadout(draft, catalog)
|
||||
): WeaponCalculationResult[] {
|
||||
const focus = selectedFocus(draft, catalog);
|
||||
const combatEffects = selectedNodeRanks(draft, catalog).flatMap(({ node, rank }: any) =>
|
||||
(rank?.combatEffects ?? []).map((effect: any) => ({ ...effect, nodeName: node.name }))
|
||||
);
|
||||
const required = (weaponIndex: number) => {
|
||||
const missing: string[] = [];
|
||||
if (!catalog.calculation.revision) missing.push('An active ISAC calculation revision');
|
||||
else if (!catalog.calculation.profiles.length) missing.push('Published attribute calculation profiles');
|
||||
if (!draft.weapons[weaponIndex].itemId) missing.push(`${weaponIndex ? 'Secondary' : 'Primary'} weapon`);
|
||||
for (const slot of gearSlots) if (!draft.gear[slot].itemId) missing.push(`${slot} gear`);
|
||||
if (!draft.protocol.itemId) missing.push('OS Protocol');
|
||||
if (!draft.specialization.focusId) missing.push('Specialization focus');
|
||||
else if ((focus?.abilities.length ?? 0) !== 3) missing.push('Three configured focus abilities');
|
||||
const missingChips = draft.chips.filter((chip) => !chip.setId).length;
|
||||
if (missingChips) missing.push(`${missingChips} specialization Skill Chip${missingChips === 1 ? '' : 's'}`);
|
||||
if (draft.specialization.unassignedChips.length) {
|
||||
missing.push(
|
||||
`${draft.specialization.unassignedChips.length} unresolved legacy Skill Chip${draft.specialization.unassignedChips.length === 1 ? '' : 's'}`
|
||||
);
|
||||
}
|
||||
return missing;
|
||||
};
|
||||
|
||||
return [0, 1].map((weaponIndex) => {
|
||||
const selection = draft.weapons[weaponIndex];
|
||||
const weapon = catalog.weapons.find((item) => item.id === selection.itemId);
|
||||
const missing = required(weaponIndex);
|
||||
if (missing.length) {
|
||||
return {
|
||||
weaponName: weapon?.name ?? `${weaponIndex ? 'Secondary' : 'Primary'} weapon`,
|
||||
weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON',
|
||||
status: 'insufficient',
|
||||
missing,
|
||||
revisionLabel: catalog.calculation.revision
|
||||
? `RULES R${catalog.calculation.revision.version}`
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
const bindings = calculationBindings(catalog, weapon);
|
||||
const effects = combatEffects.map((effect: any) => ({
|
||||
id: String(effect.id),
|
||||
trigger: {
|
||||
hitsRequired: Number(effect.trigger_count),
|
||||
windowSeconds: Number(effect.trigger_window_seconds)
|
||||
},
|
||||
lifecycle: {
|
||||
maxStacks: Number(effect.max_stacks),
|
||||
durationSeconds: Number(effect.duration_seconds),
|
||||
stacksPerTrigger: Number(effect.stacks_per_trigger)
|
||||
},
|
||||
attributes: Object.fromEntries(effect.modifiers.map((modifier: any) => [
|
||||
String(modifier.stat_attribute_id),
|
||||
Number(modifier.value_per_stack)
|
||||
]))
|
||||
}));
|
||||
const perEffect = Object.fromEntries(combatEffects.map((effect: any) => {
|
||||
const state = draft.specialization.effectStates[effect.id] ?? { mode: 'off' };
|
||||
return [effect.id, {
|
||||
mode: state.mode,
|
||||
...(state.overrides ?? {})
|
||||
}];
|
||||
}));
|
||||
const result = calculateCombat({
|
||||
weapon: {
|
||||
baseDamage: selection.damage,
|
||||
rpm: Number(weapon?.rpm),
|
||||
magazine: Number(weapon?.magazine),
|
||||
reloadSeconds: Number(weapon?.reload_speed),
|
||||
headshotMultiplier: 2
|
||||
},
|
||||
attributes: Object.fromEntries(summary.stats.map((stat) => [stat.id, stat.value])),
|
||||
bindings,
|
||||
stageOrder: catalog.calculation.stages
|
||||
.map((stage) => calculationStage(catalog, String(stage.stage_key)))
|
||||
.filter((stage): stage is CalculationStage => stage !== null),
|
||||
stackingEffects: effects,
|
||||
assumptions: {
|
||||
mode: 'off',
|
||||
...draft.specialization.combatProfile,
|
||||
perEffect
|
||||
}
|
||||
});
|
||||
if (result.status === 'INSUFFICIENT_DATA') {
|
||||
return {
|
||||
weaponName: weapon?.name ?? `${weaponIndex ? 'Secondary' : 'Primary'} weapon`,
|
||||
weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON',
|
||||
status: 'insufficient',
|
||||
missing: result.missing.map((item) =>
|
||||
item.startsWith('binding:')
|
||||
? `Calculation profile for ${attribute(catalog, item.slice(8))?.name ?? item.slice(8)}`
|
||||
: item.replaceAll('.', ' ')
|
||||
),
|
||||
revisionLabel: `RULES R${catalog.calculation.revision.version}`
|
||||
};
|
||||
}
|
||||
return {
|
||||
weaponName: weapon.name,
|
||||
weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON',
|
||||
status: 'complete',
|
||||
bodyHit: result.hitValues?.normal,
|
||||
criticalBodyHit: result.hitValues?.critical,
|
||||
headshot: result.hitValues?.headshot,
|
||||
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
|
||||
};
|
||||
}),
|
||||
revisionLabel: `RULES R${catalog.calculation.revision.version}`
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -3,14 +3,30 @@ 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 EffectMode = 'off' | 'peak' | 'encounter';
|
||||
export type ConditionalEffectSelection = { mode: EffectMode; overrides?: Partial<CombatProfile> };
|
||||
export type SpecializationSelection = {
|
||||
focusId: string;
|
||||
nodeRanks: Record<string, number>;
|
||||
effectStates: Record<string, ConditionalEffectSelection>;
|
||||
combatProfile: CombatProfile;
|
||||
unassignedChips: ChipSelection[];
|
||||
};
|
||||
export type LoadoutDraft = {
|
||||
version: 1;
|
||||
version: 2;
|
||||
buildName: string;
|
||||
gear: Record<string, GearSelection>;
|
||||
weapons: [WeaponSelection, WeaponSelection];
|
||||
chips: [ChipSelection, ChipSelection, ChipSelection];
|
||||
chips: [ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection];
|
||||
protocol: ProtocolSelection;
|
||||
activeTalents: string[];
|
||||
specialization: SpecializationSelection;
|
||||
};
|
||||
|
||||
type LegacyLoadoutDraft = Omit<LoadoutDraft, 'version' | 'chips' | 'specialization'> & {
|
||||
version: 1;
|
||||
chips: [ChipSelection, ChipSelection, ChipSelection];
|
||||
};
|
||||
|
||||
export const gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'] as const;
|
||||
@@ -18,16 +34,81 @@ 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 createDraft = (): LoadoutDraft => ({
|
||||
version: 1,
|
||||
version: 2,
|
||||
buildName: 'Untitled loadout',
|
||||
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
|
||||
weapons: [blankWeapon(), blankWeapon()],
|
||||
chips: [blankChip(), blankChip(), blankChip()],
|
||||
chips: [blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip()],
|
||||
protocol: { itemId: '', level: 1, talentUpgrades: 0 },
|
||||
activeTalents: []
|
||||
activeTalents: [],
|
||||
specialization: {
|
||||
focusId: '',
|
||||
nodeRanks: {},
|
||||
effectStates: {},
|
||||
combatProfile: defaultCombatProfile(),
|
||||
unassignedChips: []
|
||||
}
|
||||
});
|
||||
|
||||
export function migrateLoadoutDraft(value: unknown): LoadoutDraft | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const draft = value as {
|
||||
version?: number;
|
||||
buildName?: string;
|
||||
gear?: Record<string, GearSelection>;
|
||||
weapons?: WeaponSelection[];
|
||||
chips?: ChipSelection[];
|
||||
protocol?: ProtocolSelection;
|
||||
activeTalents?: string[];
|
||||
specialization?: SpecializationSelection;
|
||||
};
|
||||
if (typeof draft.buildName !== 'string' || !draft.gear || !Array.isArray(draft.weapons)
|
||||
|| draft.weapons.length !== 2 || !draft.protocol || !Array.isArray(draft.chips)) return null;
|
||||
if (draft.version === 1 && draft.chips.length === 3) {
|
||||
const migrated = createDraft();
|
||||
return {
|
||||
...migrated,
|
||||
...draft,
|
||||
version: 2,
|
||||
gear: draft.gear,
|
||||
weapons: draft.weapons as [WeaponSelection, WeaponSelection],
|
||||
chips: migrated.chips,
|
||||
protocol: draft.protocol,
|
||||
activeTalents: draft.activeTalents ?? [],
|
||||
specialization: {
|
||||
...migrated.specialization,
|
||||
unassignedChips: draft.chips.map((chip) => structuredClone(chip))
|
||||
}
|
||||
};
|
||||
}
|
||||
if (draft.version !== 2) return null;
|
||||
const fresh = createDraft();
|
||||
const chips = [...draft.chips.slice(0, 9)];
|
||||
while (chips.length < 9) chips.push(blankChip());
|
||||
return {
|
||||
...fresh,
|
||||
...draft,
|
||||
version: 2,
|
||||
gear: draft.gear,
|
||||
weapons: draft.weapons as [WeaponSelection, WeaponSelection],
|
||||
chips: chips as LoadoutDraft['chips'],
|
||||
protocol: draft.protocol,
|
||||
activeTalents: draft.activeTalents ?? [],
|
||||
specialization: {
|
||||
...fresh.specialization,
|
||||
...(draft.specialization ?? {}),
|
||||
nodeRanks: { ...(draft.specialization?.nodeRanks ?? {}) },
|
||||
effectStates: { ...(draft.specialization?.effectStates ?? {}) },
|
||||
combatProfile: { ...fresh.specialization.combatProfile, ...(draft.specialization?.combatProfile ?? {}) },
|
||||
unassignedChips: Array.isArray(draft.specialization?.unassignedChips)
|
||||
? draft.specialization.unassignedChips
|
||||
: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const clamp = (value: unknown, min: unknown, max: unknown) => {
|
||||
const low = Number(min), high = Number(max), numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return Number.isFinite(low) ? low : 0;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { calculateCombat, type CombatCalculationInput } from './combat.js';
|
||||
|
||||
const base = (overrides: Partial<CombatCalculationInput> = {}): CombatCalculationInput => ({
|
||||
weapon: { baseDamage: 100, rpm: 600, magazine: 10, reloadSeconds: 2, headshotMultiplier: 2 },
|
||||
attributes: { weapon: 20, critChance: 25, critDamage: 50, headshotChance: 0, final: 10 },
|
||||
bindings: [
|
||||
{ attributeId: 'weapon', stage: 'weapon_damage', operator: 'add' },
|
||||
{ attributeId: 'critChance', stage: 'critical_chance', operator: 'add' },
|
||||
{ attributeId: 'critDamage', stage: 'critical_damage', operator: 'add' },
|
||||
{ attributeId: 'headshotChance', stage: 'headshot_chance', operator: 'add' },
|
||||
{ attributeId: 'final', stage: 'final_damage', operator: 'add' }
|
||||
],
|
||||
assumptions: { mode: 'off', hitRate: 100, headshotRate: 0, firingActivity: 100, encounterSeconds: 20 },
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('calculateCombat', () => {
|
||||
it('calculates four hit values and reload-aware DPS', () => {
|
||||
const result = calculateCombat(base());
|
||||
expect(result.status).toBe('OK');
|
||||
expect(result.hitValues).toEqual({ normal: 132, critical: 198, headshot: 264, criticalHeadshot: 396 });
|
||||
expect(result.burstDps).toBe(1485);
|
||||
expect(result.sustainedDps).toBeCloseTo(495);
|
||||
});
|
||||
|
||||
it('applies weights, hard caps, and a configured curve safely', () => {
|
||||
const result = calculateCombat(base({
|
||||
attributes: { weapon: 100 },
|
||||
bindings: [{
|
||||
attributeId: 'weapon',
|
||||
stage: 'weapon_damage',
|
||||
operator: 'add',
|
||||
weight: 2,
|
||||
softCap: 50,
|
||||
hardCap: 80,
|
||||
curveType: 'power',
|
||||
curveParameter: 1
|
||||
}]
|
||||
}));
|
||||
expect(result.hitValues?.normal).toBe(180);
|
||||
});
|
||||
|
||||
it('keeps effects off, applies peak stacks, and honors per-effect overrides', () => {
|
||||
const stackingEffects = [{
|
||||
id: 'relentless',
|
||||
trigger: { hitsRequired: 9, windowSeconds: 3 },
|
||||
lifecycle: { maxStacks: 10, durationSeconds: 10, stacksPerTrigger: 1 },
|
||||
attributes: { weapon: 3, critDamage: 1 }
|
||||
}];
|
||||
const off = calculateCombat(base({ stackingEffects }));
|
||||
const peak = calculateCombat(base({
|
||||
stackingEffects,
|
||||
assumptions: { mode: 'off', perEffect: { relentless: { mode: 'peak' } } }
|
||||
}));
|
||||
expect(off.hitValues?.normal).toBe(132);
|
||||
expect(peak.hitValues?.normal).toBeCloseTo(165);
|
||||
expect(peak.stackSummaries[0].stacksAtEnd).toBe(10);
|
||||
});
|
||||
|
||||
it('models encounter stack ramp and refresh with the weapon firing cycle', () => {
|
||||
const result = calculateCombat(base({
|
||||
stackingEffects: [{
|
||||
id: 'relentless',
|
||||
trigger: { hitsRequired: 9, windowSeconds: 3 },
|
||||
lifecycle: { maxStacks: 10, durationSeconds: 10, stacksPerTrigger: 1 },
|
||||
attributes: { weapon: 3 }
|
||||
}],
|
||||
assumptions: { mode: 'encounter', hitRate: 100, firingActivity: 100, encounterSeconds: 30 }
|
||||
}));
|
||||
expect(result.encounterDps).not.toBeNull();
|
||||
expect(result.encounterDps!).toBeGreaterThan(495);
|
||||
expect(result.stackSummaries[0].maxStacksReached).toBe(10);
|
||||
expect(result.stackSummaries[0].maintained).toBe(true);
|
||||
});
|
||||
|
||||
it('does not trigger when expected hits cannot meet the trigger window', () => {
|
||||
const result = calculateCombat(base({
|
||||
stackingEffects: [{
|
||||
id: 'slow',
|
||||
trigger: { hitsRequired: 9, windowSeconds: 0.5 },
|
||||
lifecycle: { maxStacks: 10, durationSeconds: 10 },
|
||||
attributes: { weapon: 3 }
|
||||
}],
|
||||
assumptions: { mode: 'encounter', hitRate: 10, firingActivity: 50, encounterSeconds: 20 }
|
||||
}));
|
||||
expect(result.stackSummaries[0]).toMatchObject({ maxStacksReached: 0, averageStacks: 0, maintained: false });
|
||||
});
|
||||
|
||||
it('returns explicit requirements instead of partial results', () => {
|
||||
const result = calculateCombat(base({
|
||||
weapon: { baseDamage: 100, rpm: 0, magazine: 0, reloadSeconds: -1 },
|
||||
assumptions: { mode: 'encounter' },
|
||||
stackingEffects: [{
|
||||
id: 'need-profile',
|
||||
trigger: { hitsRequired: 1, windowSeconds: 1 },
|
||||
lifecycle: { maxStacks: 1, durationSeconds: 1 },
|
||||
attributes: {}
|
||||
}]
|
||||
}));
|
||||
expect(result).toMatchObject({ status: 'INSUFFICIENT_DATA', hitValues: null, burstDps: null });
|
||||
expect(result.missing).toEqual(expect.arrayContaining([
|
||||
'weapon.rpm',
|
||||
'weapon.magazine',
|
||||
'weapon.reloadSeconds',
|
||||
'assumptions.hitRate',
|
||||
'assumptions.firingActivity',
|
||||
'assumptions.encounterSeconds'
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,405 @@
|
||||
export type CalculationStage =
|
||||
| 'flat_damage'
|
||||
| 'weapon_damage'
|
||||
| 'critical_chance'
|
||||
| 'critical_damage'
|
||||
| 'headshot_chance'
|
||||
| 'headshot_damage'
|
||||
| 'final_damage';
|
||||
export type CalculationOperator = 'add' | 'multiply';
|
||||
export type CurveType = 'none' | 'power' | 'smoothstep';
|
||||
export type AttributeBinding = {
|
||||
attributeId: string;
|
||||
stage: CalculationStage;
|
||||
operator: CalculationOperator;
|
||||
weight?: number;
|
||||
softCap?: number | null;
|
||||
hardCap?: number | null;
|
||||
curveType?: CurveType;
|
||||
curveParameter?: number | null;
|
||||
};
|
||||
export type StatValues = Record<string, number>;
|
||||
export type WeaponCombatInput = {
|
||||
baseDamage?: number;
|
||||
rpm?: number;
|
||||
magazine?: number;
|
||||
reloadSeconds?: number;
|
||||
headshotMultiplier?: number;
|
||||
};
|
||||
export type StackingMode = 'off' | 'peak' | 'encounter';
|
||||
export type TriggerRule = { hitsRequired: number; windowSeconds: number };
|
||||
export type StackLifecycle = { maxStacks: number; durationSeconds: number; stacksPerTrigger?: number };
|
||||
export type StackingEffect = {
|
||||
id: string;
|
||||
trigger: TriggerRule;
|
||||
lifecycle: StackLifecycle;
|
||||
attributes: StatValues;
|
||||
};
|
||||
export type CombatAssumptions = {
|
||||
mode: StackingMode;
|
||||
hitRate?: number;
|
||||
headshotRate?: number;
|
||||
firingActivity?: number;
|
||||
encounterSeconds?: number;
|
||||
perEffect?: Record<string, Partial<Omit<CombatAssumptions, 'perEffect'>>>;
|
||||
};
|
||||
export type MissingRequirement =
|
||||
| 'weapon.baseDamage'
|
||||
| 'weapon.rpm'
|
||||
| 'weapon.magazine'
|
||||
| 'weapon.reloadSeconds'
|
||||
| 'assumptions.hitRate'
|
||||
| 'assumptions.firingActivity'
|
||||
| 'assumptions.encounterSeconds'
|
||||
| `binding:${string}`;
|
||||
export type DamageValues = {
|
||||
normal: number;
|
||||
critical: number;
|
||||
headshot: number;
|
||||
criticalHeadshot: number;
|
||||
};
|
||||
export type StackSummary = {
|
||||
effectId: string;
|
||||
mode: StackingMode;
|
||||
stacksAtEnd: number;
|
||||
maxStacksReached: number;
|
||||
buildUpSeconds: number | null;
|
||||
averageStacks: number;
|
||||
maintained: boolean;
|
||||
};
|
||||
export type CombatCalculation = {
|
||||
status: 'OK' | 'INSUFFICIENT_DATA';
|
||||
missing: MissingRequirement[];
|
||||
hitValues: DamageValues | null;
|
||||
burstDps: number | null;
|
||||
sustainedDps: number | null;
|
||||
encounterDps: number | null;
|
||||
stackSummaries: StackSummary[];
|
||||
};
|
||||
export type CombatCalculationInput = {
|
||||
weapon: WeaponCombatInput;
|
||||
attributes: StatValues;
|
||||
bindings: AttributeBinding[];
|
||||
stageOrder?: CalculationStage[];
|
||||
stackingEffects?: StackingEffect[];
|
||||
assumptions: CombatAssumptions;
|
||||
};
|
||||
|
||||
type ResolvedAssumptions = {
|
||||
mode: StackingMode;
|
||||
hitRate: number;
|
||||
headshotRate: number;
|
||||
firingActivity: number;
|
||||
encounterSeconds: number;
|
||||
};
|
||||
type StackState = {
|
||||
stacks: number;
|
||||
expiresAt: number;
|
||||
maxStacksReached: number;
|
||||
buildUpSeconds: number | null;
|
||||
};
|
||||
type TimedStackEffect = {
|
||||
effect: StackingEffect;
|
||||
assumptions: ResolvedAssumptions;
|
||||
events: number[];
|
||||
};
|
||||
|
||||
const EPSILON = 0.000_001;
|
||||
const finitePositive = (value: unknown) => Number.isFinite(Number(value)) && Number(value) > 0;
|
||||
const finiteNonNegative = (value: unknown) => Number.isFinite(Number(value)) && Number(value) >= 0;
|
||||
const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));
|
||||
const percent = (value: unknown, fallback = 0) => clamp(Number.isFinite(Number(value)) ? Number(value) : fallback, 0, 100) / 100;
|
||||
|
||||
function resolveAssumptions(base: CombatAssumptions, effectId: string): ResolvedAssumptions {
|
||||
const override = base.perEffect?.[effectId] ?? {};
|
||||
return {
|
||||
mode: override.mode ?? base.mode,
|
||||
hitRate: percent(override.hitRate ?? base.hitRate, 100),
|
||||
headshotRate: percent(override.headshotRate ?? base.headshotRate, 0),
|
||||
firingActivity: percent(override.firingActivity ?? base.firingActivity, 100),
|
||||
encounterSeconds: Number(override.encounterSeconds ?? base.encounterSeconds ?? 0)
|
||||
};
|
||||
}
|
||||
|
||||
function transformedValue(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 = finiteNonNegative(binding.softCap) ? Number(binding.softCap) : null;
|
||||
const hardCap = finiteNonNegative(binding.hardCap) ? 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, EPSILON), parameter);
|
||||
} else {
|
||||
const ceiling = hardCap ?? Math.max(softCap * 2, softCap + 1);
|
||||
const t = clamp(overflow / Math.max(ceiling - softCap, EPSILON), 0, 1);
|
||||
value = softCap + (ceiling - softCap) * (t * t * (3 - 2 * t));
|
||||
}
|
||||
}
|
||||
if (hardCap !== null) value = Math.min(value, hardCap);
|
||||
return value * sign;
|
||||
}
|
||||
|
||||
function addAttributes(target: StatValues, incoming: StatValues, multiplier = 1) {
|
||||
for (const [id, value] of Object.entries(incoming)) {
|
||||
if (Number.isFinite(value)) target[id] = (target[id] ?? 0) + value * multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
function requiredData(input: CombatCalculationInput): MissingRequirement[] {
|
||||
const missing: MissingRequirement[] = [];
|
||||
if (!finitePositive(input.weapon.baseDamage)) missing.push('weapon.baseDamage');
|
||||
if (!finitePositive(input.weapon.rpm)) missing.push('weapon.rpm');
|
||||
if (!Number.isInteger(input.weapon.magazine) || Number(input.weapon.magazine) < 1) missing.push('weapon.magazine');
|
||||
if (!finiteNonNegative(input.weapon.reloadSeconds)) missing.push('weapon.reloadSeconds');
|
||||
const boundIds = new Set(input.bindings.map((binding) => binding.attributeId));
|
||||
for (const binding of input.bindings) {
|
||||
if (!Number.isFinite(input.attributes[binding.attributeId] ?? 0)) missing.push(`binding:${binding.attributeId}`);
|
||||
}
|
||||
if (input.assumptions.mode === 'encounter') {
|
||||
if (!finitePositive(input.assumptions.hitRate)) missing.push('assumptions.hitRate');
|
||||
if (!finitePositive(input.assumptions.firingActivity)) missing.push('assumptions.firingActivity');
|
||||
if (!finitePositive(input.assumptions.encounterSeconds)) missing.push('assumptions.encounterSeconds');
|
||||
}
|
||||
for (const effect of input.stackingEffects ?? []) {
|
||||
const assumptions = resolveAssumptions(input.assumptions, effect.id);
|
||||
if (assumptions.mode === 'encounter') {
|
||||
const override = input.assumptions.perEffect?.[effect.id];
|
||||
if (!finitePositive(override?.hitRate ?? input.assumptions.hitRate)) missing.push('assumptions.hitRate');
|
||||
if (!finitePositive(override?.firingActivity ?? input.assumptions.firingActivity)) missing.push('assumptions.firingActivity');
|
||||
if (!finitePositive(override?.encounterSeconds ?? input.assumptions.encounterSeconds)) missing.push('assumptions.encounterSeconds');
|
||||
}
|
||||
if (assumptions.mode !== 'off') for (const id of Object.keys(effect.attributes)) {
|
||||
if (!boundIds.has(id)) missing.push(`binding:${id}`);
|
||||
}
|
||||
}
|
||||
return [...new Set(missing)];
|
||||
}
|
||||
|
||||
function stageValues(attributes: StatValues, bindings: AttributeBinding[], stage: CalculationStage) {
|
||||
let additive = 0;
|
||||
let multiplicative = 1;
|
||||
for (const binding of bindings.filter((item) => item.stage === stage)) {
|
||||
const value = transformedValue(Number(attributes[binding.attributeId] ?? 0), binding);
|
||||
if (binding.operator === 'add') additive += value;
|
||||
else multiplicative *= value;
|
||||
}
|
||||
return { additive, multiplicative };
|
||||
}
|
||||
|
||||
function damageBase(
|
||||
weapon: Required<WeaponCombatInput>,
|
||||
attributes: StatValues,
|
||||
bindings: AttributeBinding[],
|
||||
stageOrder?: CalculationStage[]
|
||||
) {
|
||||
let damage = weapon.baseDamage;
|
||||
const order = stageOrder?.length
|
||||
? stageOrder
|
||||
: ['flat_damage', 'weapon_damage', 'final_damage'] satisfies CalculationStage[];
|
||||
for (const stage of order) {
|
||||
if (!['flat_damage', 'weapon_damage', 'final_damage'].includes(stage)) continue;
|
||||
const values = stageValues(attributes, bindings, stage);
|
||||
if (stage === 'flat_damage') damage = (damage + values.additive) * values.multiplicative;
|
||||
else damage *= (1 + values.additive / 100) * values.multiplicative;
|
||||
}
|
||||
return damage;
|
||||
}
|
||||
|
||||
function hitValues(
|
||||
weapon: Required<WeaponCombatInput>,
|
||||
attributes: StatValues,
|
||||
bindings: AttributeBinding[],
|
||||
stageOrder?: CalculationStage[]
|
||||
): DamageValues {
|
||||
const normal = damageBase(weapon, attributes, bindings, stageOrder);
|
||||
const critical = stageValues(attributes, bindings, 'critical_damage');
|
||||
const headshot = stageValues(attributes, bindings, 'headshot_damage');
|
||||
const criticalFactor = (1 + critical.additive / 100) * critical.multiplicative;
|
||||
const headshotFactor = weapon.headshotMultiplier * (1 + headshot.additive / 100) * headshot.multiplicative;
|
||||
return {
|
||||
normal,
|
||||
critical: normal * criticalFactor,
|
||||
headshot: normal * headshotFactor,
|
||||
criticalHeadshot: normal * criticalFactor * headshotFactor
|
||||
};
|
||||
}
|
||||
|
||||
function dpsFor(
|
||||
weapon: Required<WeaponCombatInput>,
|
||||
attributes: StatValues,
|
||||
bindings: AttributeBinding[],
|
||||
assumptions: ResolvedAssumptions,
|
||||
stageOrder?: CalculationStage[]
|
||||
) {
|
||||
const values = hitValues(weapon, attributes, bindings, stageOrder);
|
||||
const critical = stageValues(attributes, bindings, 'critical_chance');
|
||||
const boundCriticalChance = clamp(critical.additive / 100, 0, 1);
|
||||
const configuredHeadshot = stageValues(attributes, bindings, 'headshot_chance');
|
||||
const boundHeadshotChance = clamp(configuredHeadshot.additive / 100, 0, 1);
|
||||
const headshotChance = assumptions.headshotRate || boundHeadshotChance;
|
||||
const average = values.normal * (1 - boundCriticalChance) * (1 - headshotChance)
|
||||
+ values.critical * boundCriticalChance * (1 - headshotChance)
|
||||
+ values.headshot * (1 - boundCriticalChance) * headshotChance
|
||||
+ values.criticalHeadshot * boundCriticalChance * headshotChance;
|
||||
const shotsPerSecond = weapon.rpm / 60;
|
||||
return {
|
||||
values,
|
||||
burst: average * shotsPerSecond,
|
||||
sustained: average * weapon.magazine / (weapon.magazine / shotsPerSecond + weapon.reloadSeconds)
|
||||
};
|
||||
}
|
||||
|
||||
function triggerEvents(weapon: Required<WeaponCombatInput>, effect: StackingEffect, assumptions: ResolvedAssumptions) {
|
||||
if (assumptions.mode !== 'encounter' || effect.trigger.hitsRequired < 1 || effect.trigger.windowSeconds <= 0) return [];
|
||||
const shotInterval = (60 / weapon.rpm) / Math.max(assumptions.firingActivity, EPSILON);
|
||||
let magazineRemaining = weapon.magazine;
|
||||
let expectedHits = 0;
|
||||
let counterStartedAt = 0;
|
||||
const events: number[] = [];
|
||||
for (let time = 0; time <= assumptions.encounterSeconds + EPSILON;) {
|
||||
if (magazineRemaining === 0) {
|
||||
time += weapon.reloadSeconds;
|
||||
magazineRemaining = weapon.magazine;
|
||||
if (time - counterStartedAt > effect.trigger.windowSeconds) {
|
||||
expectedHits = 0;
|
||||
counterStartedAt = time;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (expectedHits === 0) counterStartedAt = time;
|
||||
expectedHits += assumptions.hitRate;
|
||||
magazineRemaining -= 1;
|
||||
if (time - counterStartedAt > effect.trigger.windowSeconds) {
|
||||
expectedHits = assumptions.hitRate;
|
||||
counterStartedAt = time;
|
||||
}
|
||||
if (expectedHits + EPSILON >= effect.trigger.hitsRequired) {
|
||||
events.push(time);
|
||||
expectedHits = 0;
|
||||
counterStartedAt = time;
|
||||
}
|
||||
time += shotInterval;
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function stateAt(effect: TimedStackEffect, time: number): StackState {
|
||||
if (effect.assumptions.mode === 'off') {
|
||||
return { stacks: 0, expiresAt: 0, maxStacksReached: 0, buildUpSeconds: null };
|
||||
}
|
||||
if (effect.assumptions.mode === 'peak') {
|
||||
return {
|
||||
stacks: effect.effect.lifecycle.maxStacks,
|
||||
expiresAt: Infinity,
|
||||
maxStacksReached: effect.effect.lifecycle.maxStacks,
|
||||
buildUpSeconds: 0
|
||||
};
|
||||
}
|
||||
const state: StackState = { stacks: 0, expiresAt: 0, maxStacksReached: 0, buildUpSeconds: null };
|
||||
for (const eventTime of effect.events) {
|
||||
if (eventTime - time > EPSILON) break;
|
||||
if (eventTime - state.expiresAt > EPSILON) state.stacks = 0;
|
||||
state.stacks = Math.min(
|
||||
effect.effect.lifecycle.maxStacks,
|
||||
state.stacks + Math.max(1, effect.effect.lifecycle.stacksPerTrigger ?? 1)
|
||||
);
|
||||
state.expiresAt = eventTime + effect.effect.lifecycle.durationSeconds;
|
||||
state.maxStacksReached = Math.max(state.maxStacksReached, state.stacks);
|
||||
if (state.stacks === effect.effect.lifecycle.maxStacks && state.buildUpSeconds === null) {
|
||||
state.buildUpSeconds = eventTime;
|
||||
}
|
||||
}
|
||||
if (time - state.expiresAt > EPSILON) state.stacks = 0;
|
||||
return state;
|
||||
}
|
||||
|
||||
function stackAttributes(base: StatValues, effects: TimedStackEffect[], time: number) {
|
||||
const result = { ...base };
|
||||
for (const effect of effects) addAttributes(result, effect.effect.attributes, stateAt(effect, time).stacks);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function calculateCombat(input: CombatCalculationInput): CombatCalculation {
|
||||
const missing = requiredData(input);
|
||||
if (missing.length) {
|
||||
return {
|
||||
status: 'INSUFFICIENT_DATA',
|
||||
missing,
|
||||
hitValues: null,
|
||||
burstDps: null,
|
||||
sustainedDps: null,
|
||||
encounterDps: null,
|
||||
stackSummaries: []
|
||||
};
|
||||
}
|
||||
const weapon: Required<WeaponCombatInput> = {
|
||||
baseDamage: Number(input.weapon.baseDamage),
|
||||
rpm: Number(input.weapon.rpm),
|
||||
magazine: Number(input.weapon.magazine),
|
||||
reloadSeconds: Number(input.weapon.reloadSeconds),
|
||||
headshotMultiplier: Number(input.weapon.headshotMultiplier ?? 2)
|
||||
};
|
||||
const effects: TimedStackEffect[] = (input.stackingEffects ?? []).map((effect) => {
|
||||
const assumptions = resolveAssumptions(input.assumptions, effect.id);
|
||||
return { effect, assumptions, events: triggerEvents(weapon, effect, assumptions) };
|
||||
});
|
||||
const globalAssumptions = resolveAssumptions(input.assumptions, '');
|
||||
const finalTime = Math.max(
|
||||
globalAssumptions.encounterSeconds,
|
||||
...effects.filter((effect) => effect.assumptions.mode === 'encounter').map((effect) => effect.assumptions.encounterSeconds)
|
||||
);
|
||||
const current = dpsFor(
|
||||
weapon,
|
||||
stackAttributes(input.attributes, effects, finalTime),
|
||||
input.bindings,
|
||||
globalAssumptions,
|
||||
input.stageOrder
|
||||
);
|
||||
let encounterDps: number | null = null;
|
||||
if (effects.some((effect) => effect.assumptions.mode === 'encounter') && finalTime > 0) {
|
||||
const steps = Math.max(30, Math.min(600, Math.ceil(finalTime * 4)));
|
||||
let total = 0;
|
||||
for (let index = 0; index < steps; index += 1) {
|
||||
const time = finalTime * (index + 0.5) / steps;
|
||||
const result = dpsFor(
|
||||
weapon,
|
||||
stackAttributes(input.attributes, effects, time),
|
||||
input.bindings,
|
||||
globalAssumptions,
|
||||
input.stageOrder
|
||||
);
|
||||
total += result.sustained * globalAssumptions.firingActivity;
|
||||
}
|
||||
encounterDps = total / steps;
|
||||
}
|
||||
return {
|
||||
status: 'OK',
|
||||
missing: [],
|
||||
hitValues: current.values,
|
||||
burstDps: current.burst,
|
||||
sustainedDps: current.sustained,
|
||||
encounterDps,
|
||||
stackSummaries: effects.map((item) => {
|
||||
const end = stateAt(item, finalTime);
|
||||
const duration = item.assumptions.mode === 'encounter' ? item.assumptions.encounterSeconds : 0;
|
||||
const samples = item.assumptions.mode === 'encounter' && duration > 0 ? 100 : 1;
|
||||
let sum = 0;
|
||||
for (let index = 0; index < samples; index += 1) {
|
||||
sum += stateAt(item, duration * (index + 0.5) / samples).stacks;
|
||||
}
|
||||
return {
|
||||
effectId: item.effect.id,
|
||||
mode: item.assumptions.mode,
|
||||
stacksAtEnd: end.stacks,
|
||||
maxStacksReached: end.maxStacksReached,
|
||||
buildUpSeconds: end.buildUpSeconds,
|
||||
averageStacks: sum / samples,
|
||||
maintained: end.maxStacksReached === item.effect.lifecycle.maxStacks
|
||||
&& (item.assumptions.mode === 'peak' || end.stacks > 0)
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -40,6 +40,7 @@
|
||||
: action === 'saveSpecialization' ? 'SPECIALIZATION'
|
||||
: action === 'saveFocus' ? 'FOCUS'
|
||||
: action === 'saveAbility' ? 'CLASS ABILITY'
|
||||
: action === 'saveCombatEffect' ? 'SHARED COMBAT EFFECT'
|
||||
: action === 'saveFocusAbilities' ? 'FOCUS ABILITY SET'
|
||||
: 'TREE NODE'
|
||||
);
|
||||
@@ -79,6 +80,12 @@
|
||||
{ label: 'COOLDOWN · SECONDS', current: specializationRecord?.cooldown_seconds, proposed: proposed.cooldownSeconds },
|
||||
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
|
||||
]
|
||||
: action === 'saveCombatEffect'
|
||||
? [
|
||||
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
|
||||
{ label: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId },
|
||||
{ label: 'DESCRIPTION TEMPLATE', current: specializationRecord?.description_template, proposed: proposed.descriptionTemplate }
|
||||
]
|
||||
: action === 'saveNode'
|
||||
? [
|
||||
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
|
||||
@@ -102,6 +109,9 @@
|
||||
const currentNodeEffects = $derived((currentSnapshot?.effects ?? []) as any[]);
|
||||
const currentNodeCosts = $derived((currentSnapshot?.costs ?? []) as any[]);
|
||||
const currentRankProfiles = $derived((currentSnapshot?.rankProfiles ?? []) as any[]);
|
||||
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 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}` : ''}`;
|
||||
@@ -173,7 +183,19 @@
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
{:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveFocusAbilities','saveNode'].includes(action)}
|
||||
{:else if module === 'calculations'}
|
||||
<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}
|
||||
</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>
|
||||
{#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>
|
||||
{:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveCombatEffect','saveFocusAbilities','saveNode'].includes(action)}
|
||||
<div class="proposal-compare-grid specialization-proposal-preview">
|
||||
<article class="proposal-editor-preview before">
|
||||
<header><span>CURRENT LIVE · {specializationKind}</span><b>{specializationRecord ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
|
||||
@@ -221,9 +243,10 @@
|
||||
<div class="proposal-rank-preview">
|
||||
{#each proposedRanks as rank}
|
||||
<section>
|
||||
<header><span>RANK {rank.rank}</span><b>{rank.effects?.length ?? 0} EFFECTS</b></header>
|
||||
<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}
|
||||
<small>{Object.entries(rank.costs ?? {}).filter(([, quantity]) => Number(quantity) > 0).map(([resource, quantity]) => `${resource} × ${quantity}`).join(' · ') || 'CREDITS ONLY'}</small>
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
className?: string;
|
||||
icon?: GameIconName;
|
||||
iconOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
let {
|
||||
@@ -56,6 +57,7 @@
|
||||
value={option.value}
|
||||
checked={mode === 'radio' ? value === option.value : selected.includes(option.value)}
|
||||
{required}
|
||||
disabled={option.disabled}
|
||||
onchange={(event) => change(option.value, event.currentTarget.checked)}
|
||||
/>
|
||||
<span class:icon-only={option.iconOnly} class={option.className ?? ''} title={option.iconOnly ? option.label : undefined}>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import RangeValueControl from '$lib/components/RangeValueControl.svelte';
|
||||
|
||||
export type CombatProfile = {
|
||||
encounterSeconds: number;
|
||||
hitRate: number;
|
||||
headshotRate: number;
|
||||
firingActivity: number;
|
||||
};
|
||||
|
||||
let {
|
||||
profile = $bindable<CombatProfile>(),
|
||||
disabled = false
|
||||
}: {
|
||||
profile: CombatProfile;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
function update(key: keyof CombatProfile, value: number) {
|
||||
profile = { ...profile, [key]: value };
|
||||
}
|
||||
</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>
|
||||
</div>
|
||||
<small>MODELED, NOT LIVE COMBAT</small>
|
||||
</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)} />
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.combat-assumptions{padding:var(--shd-gap-md);border:1px solid var(--shd-border);border-left:2px solid var(--shd-orange);background:var(--shd-surface)}
|
||||
.section-head{display:flex;align-items:start;justify-content:space-between;gap:var(--shd-gap-md)}
|
||||
.eyebrow{margin:0 0 3px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.3px}
|
||||
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}
|
||||
.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}
|
||||
@media(max-width:620px){.section-head{display:grid}.section-head small{width:max-content}.assumption-grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<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>;
|
||||
};
|
||||
|
||||
let {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
facts,
|
||||
appliedState,
|
||||
effectState = $bindable<ConditionalEffectState>(),
|
||||
disabled = false
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
facts: string[];
|
||||
appliedState: string;
|
||||
effectState: ConditionalEffectState;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
let advanced = $state(false);
|
||||
const modes = [
|
||||
{ value: 'off', label: 'Off' },
|
||||
{ value: 'peak', label: 'Peak' },
|
||||
{ value: 'encounter', label: 'Encounter' }
|
||||
];
|
||||
|
||||
function setMode(values: string[]) {
|
||||
effectState = { ...effectState, mode: values[0] as EffectMode };
|
||||
}
|
||||
|
||||
function updateOverride(key: keyof CombatProfile, value: number) {
|
||||
effectState = { ...effectState, overrides: { ...effectState.overrides, [key]: value } };
|
||||
}
|
||||
|
||||
function clearOverrides() {
|
||||
effectState = { ...effectState, overrides: undefined };
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class:disabled class="conditional-effect" aria-labelledby={`${id}-title`}>
|
||||
<header>
|
||||
<div>
|
||||
<p>CONDITIONAL EFFECT</p>
|
||||
<h3 id={`${id}-title`}>{name}</h3>
|
||||
{#if description}<span>{description}</span>{/if}
|
||||
</div>
|
||||
<b class:active={effectState.mode !== 'off'}>{appliedState}</b>
|
||||
</header>
|
||||
<div class="facts" aria-label={`${name} facts`}>
|
||||
{#each facts as fact}<span>{fact}</span>{/each}
|
||||
</div>
|
||||
<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'}
|
||||
<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}
|
||||
</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)} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.conditional-effect{display:grid;gap:var(--shd-gap-sm);padding:var(--shd-gap-md);border:1px solid var(--shd-border);background:var(--shd-surface)}
|
||||
.conditional-effect.disabled{opacity:.55;pointer-events:none}
|
||||
header{display:flex;justify-content:space-between;align-items:start;gap:var(--shd-gap-md)}
|
||||
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;color:var(--shd-text);font-size:var(--shd-type-body);text-transform:uppercase;letter-spacing:.7px}
|
||||
header span{display:block;margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
|
||||
header>b{flex:0 0 auto;padding:5px 7px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.8px;white-space:nowrap}
|
||||
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)}
|
||||
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}}
|
||||
@media(prefers-reduced-motion:reduce){button{transition:none}}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
export type WeaponCalculationResult = {
|
||||
weaponName: string;
|
||||
weaponLabel: string;
|
||||
status: 'complete' | 'insufficient';
|
||||
bodyHit?: number;
|
||||
criticalBodyHit?: number;
|
||||
headshot?: number;
|
||||
criticalHeadshot?: number;
|
||||
burstDps?: number;
|
||||
sustainedDps?: number;
|
||||
encounterDps?: number;
|
||||
effectSummaries?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
mode: 'off' | 'peak' | 'encounter';
|
||||
averageStacks: number;
|
||||
maxStacksReached: number;
|
||||
maxStacks: number;
|
||||
stacksAtEnd: number;
|
||||
buildUpSeconds: number | null;
|
||||
maintained: boolean;
|
||||
}>;
|
||||
missing?: string[];
|
||||
revisionLabel?: string;
|
||||
};
|
||||
|
||||
let { result }: { result: WeaponCalculationResult } = $props();
|
||||
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });
|
||||
function format(value: number | undefined) { return value === undefined ? '—' : number.format(value); }
|
||||
</script>
|
||||
|
||||
<article class:incomplete={result.status === 'insufficient'} class="weapon-calculation" aria-labelledby={`${result.weaponLabel}-calculation`} aria-live="polite">
|
||||
<header>
|
||||
<div><p>{result.weaponLabel}</p><h3 id={`${result.weaponLabel}-calculation`}>{result.weaponName}</h3></div>
|
||||
<b>{result.status === 'complete' ? 'CALCULATED' : 'INSUFFICIENT DATA'}</b>
|
||||
</header>
|
||||
{#if result.status === 'insufficient'}
|
||||
<div class="missing"><strong>Calculation unavailable</strong><span>Complete the following required data:</span><ul>{#each result.missing ?? [] as item}<li>{item}</li>{/each}</ul></div>
|
||||
{:else}
|
||||
<div class="damage-grid" aria-label={`${result.weaponName} damage estimates`}>
|
||||
<span><small>BODY HIT</small><b>{format(result.bodyHit)}</b></span><span><small>CRITICAL BODY</small><b>{format(result.criticalBodyHit)}</b></span>
|
||||
<span><small>HEADSHOT</small><b>{format(result.headshot)}</b></span><span><small>CRITICAL HEADSHOT</small><b>{format(result.criticalHeadshot)}</b></span>
|
||||
</div>
|
||||
<div class="dps-grid"><span><small>BURST DPS</small><b>{format(result.burstDps)}</b></span><span><small>SUSTAINED DPS</small><b>{format(result.sustainedDps)}</b></span><span><small>ENCOUNTER DPS</small><b>{format(result.encounterDps)}</b></span></div>
|
||||
{#if result.effectSummaries?.length}
|
||||
<div class="stack-telemetry">
|
||||
<header><span>CONDITIONAL STACK TELEMETRY</span><b>{result.effectSummaries.length} EFFECTS</b></header>
|
||||
{#each result.effectSummaries as effect}
|
||||
<div>
|
||||
<strong>{effect.name}</strong>
|
||||
<span>{effect.mode.toUpperCase()}</span>
|
||||
<small>AVG {effect.averageStacks.toFixed(1)} / {effect.maxStacks}</small>
|
||||
<small>PEAK {effect.maxStacksReached} · END {effect.stacksAtEnd}</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>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<footer><span>MODELED, BEFORE ENEMY MITIGATION</span>{#if result.revisionLabel}<span>{result.revisionLabel}</span>{/if}</footer>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.weapon-calculation{display:grid;gap:var(--shd-gap-md);padding:var(--shd-gap-md);border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-surface)}
|
||||
.weapon-calculation.incomplete{border-top-color:var(--shd-danger)}header{display:flex;justify-content:space-between;gap:var(--shd-gap-md);align-items:start}header p{margin:0 0 3px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.1px}h3{margin:0;font-size:var(--shd-type-body);letter-spacing:.7px;text-transform:uppercase}header>b{padding:5px 7px;border:1px solid #74552a;background:#1d180e;color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px;white-space:nowrap}.incomplete header>b{border-color:#563734;background:#1d1212;color:var(--shd-danger)}
|
||||
.damage-grid,.dps-grid{display:grid;border:1px solid var(--shd-border)}.damage-grid{grid-template-columns:repeat(2,1fr)}.dps-grid{grid-template-columns:repeat(3,1fr)}.damage-grid span,.dps-grid span{display:grid;gap:4px;padding:9px;border-left:1px solid var(--shd-border);border-top:1px solid var(--shd-border)}.damage-grid span:nth-child(odd),.dps-grid span:first-child{border-left:0}.damage-grid span:nth-child(-n+2){border-top:0}.dps-grid b{color:var(--shd-orange);font-size:20px}.damage-grid b{font-size:var(--shd-type-body)}small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.missing{display:grid;gap:4px;padding:10px;border:1px solid #563734;border-left:2px solid var(--shd-danger);background:#1d1212;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.missing strong{color:var(--shd-danger);text-transform:uppercase;font-size:var(--shd-type-telemetry);letter-spacing:.8px}.missing ul{margin:2px 0 0;padding-left:18px}.missing li+li{margin-top:2px}footer{display:flex;justify-content:space-between;gap:var(--shd-gap-sm);color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:.6px}
|
||||
.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)}
|
||||
@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>
|
||||
@@ -41,6 +41,7 @@ export async function auditRecord(table: string, id: string): Promise<Record<str
|
||||
'specializations',
|
||||
'specialization_focuses',
|
||||
'specialization_abilities',
|
||||
'specialization_combat_effects',
|
||||
'specialization_tree_nodes',
|
||||
'roles'
|
||||
].includes(table)) {
|
||||
|
||||
@@ -6,11 +6,15 @@ 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', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips', 'specializations'] as const;
|
||||
export const DATA_MODULES = ['configuration', 'calculations', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips', 'specializations'] as const;
|
||||
export type DataModule = typeof DATA_MODULES[number];
|
||||
|
||||
const proposalResultKeys: Partial<Record<DataModule, Record<string, { saved: string; mode?: string }>>> = {
|
||||
configuration: { saveCoreRanges: { saved: 'configurationSaved' } },
|
||||
calculations: {
|
||||
saveRevision: { saved: 'revisionSaved', mode: 'revisionMode' },
|
||||
saveCalibration: { saved: 'calibrationSaved', mode: 'calibrationMode' }
|
||||
},
|
||||
attributes: { save: { saved: 'saved', mode: 'editorMode' } },
|
||||
brands: { save: { saved: 'brandSaved', mode: 'brandMode' } },
|
||||
gear: {
|
||||
@@ -34,6 +38,7 @@ const proposalResultKeys: Partial<Record<DataModule, Record<string, { saved: str
|
||||
saveSpecialization: { saved: 'specializationSaved', mode: 'specializationMode' },
|
||||
saveFocus: { saved: 'focusSaved', mode: 'focusMode' },
|
||||
saveAbility: { saved: 'abilitySaved', mode: 'abilityMode' },
|
||||
saveCombatEffect: { saved: 'combatEffectSaved', mode: 'combatEffectMode' },
|
||||
saveFocusAbilities: { saved: 'focusAbilitiesSaved' },
|
||||
saveNode: { saved: 'nodeSaved', mode: 'nodeMode' }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
|
||||
export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
const [
|
||||
attributes,
|
||||
gear,
|
||||
gearAugments,
|
||||
brands,
|
||||
brandEffects,
|
||||
weapons,
|
||||
weaponBonuses,
|
||||
talents,
|
||||
talentTiers,
|
||||
talentEffects,
|
||||
talentTemplates,
|
||||
chipSets,
|
||||
chipTwoPiece,
|
||||
chipTalents,
|
||||
chipTalentEffects,
|
||||
protocols,
|
||||
protocolPrimary,
|
||||
protocolProfiles,
|
||||
protocolSecondary,
|
||||
protocolTalents,
|
||||
protocolTalentEffects,
|
||||
specializations,
|
||||
specializationFocuses,
|
||||
specializationAbilities,
|
||||
focusAbilities,
|
||||
specializationNodes,
|
||||
specializationRanks,
|
||||
specializationRankEffects,
|
||||
specializationCombatEffects,
|
||||
specializationRankCombatEffects,
|
||||
specializationCombatModifiers,
|
||||
activeCalculationRevision,
|
||||
calculationStages,
|
||||
calculationProfiles
|
||||
] = 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(),
|
||||
(await database.prepare('SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot')).all(),
|
||||
(await database.prepare('SELECT * FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM brand_set_effects ORDER BY pieces_required')).all(),
|
||||
(await database.prepare('SELECT * FROM weapons WHERE active=1 ORDER BY category,name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM weapon_base_attribute_bonuses ORDER BY sort_order')).all(),
|
||||
(await database.prepare('SELECT * FROM talents WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(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 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(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_talent_effects ORDER BY sort_order')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocols WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_secondary_profiles')).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_effects ORDER BY sort_order')).all(),
|
||||
(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_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(),
|
||||
(await database.prepare('SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_combat_effects WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_tree_rank_combat_effects ORDER BY node_id,rank,effect_order')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_tree_rank_combat_modifiers ORDER BY rank_combat_effect_id,modifier_order')).all(),
|
||||
(await database.prepare("SELECT * FROM calculation_revisions WHERE status='active' ORDER BY version DESC LIMIT 1")).get(),
|
||||
(await database.prepare(`SELECT stage.* FROM calculation_stages stage
|
||||
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()
|
||||
]);
|
||||
|
||||
const configuredTalents = talents.map((talent) => ({
|
||||
...talent,
|
||||
template: talentTemplates.find((item) => item.talent_id === talent.id)?.template ?? '',
|
||||
tiers: talentTiers
|
||||
.filter((tier) => tier.talent_id === talent.id)
|
||||
.map((tier) => ({
|
||||
...tier,
|
||||
effects: talentEffects.filter((effect) => effect.talent_tier_id === tier.id)
|
||||
}))
|
||||
}));
|
||||
|
||||
return {
|
||||
attributes,
|
||||
gear,
|
||||
gearAugments,
|
||||
brands: brands.map((brand) => ({
|
||||
...brand,
|
||||
effects: brandEffects.filter((effect) => effect.brand_set_id === brand.id)
|
||||
})),
|
||||
weapons: weapons.map((weapon) => ({
|
||||
...weapon,
|
||||
baseBonuses: weaponBonuses.filter((bonus) => bonus.weapon_id === weapon.id)
|
||||
})),
|
||||
talents: configuredTalents,
|
||||
chipSets: chipSets.map((set) => {
|
||||
const talent = chipTalents.find((item) => item.skill_chip_set_id === set.id);
|
||||
return {
|
||||
...set,
|
||||
twoPiece: chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ?? null,
|
||||
talent: talent
|
||||
? {
|
||||
...talent,
|
||||
effects: chipTalentEffects.filter((effect) => effect.talent_id === talent.id)
|
||||
}
|
||||
: null
|
||||
};
|
||||
}),
|
||||
protocols: protocols.map((protocol) => {
|
||||
const profile = protocolProfiles.find(
|
||||
(item) =>
|
||||
item.quality === protocol.quality
|
||||
&& item.primary_attribute_type === protocol.primary_attribute_type
|
||||
);
|
||||
const talent = protocolTalents.find((item) => item.protocol_id === protocol.id);
|
||||
return {
|
||||
...protocol,
|
||||
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
|
||||
};
|
||||
}),
|
||||
specializations: specializations.map((specialization) => ({
|
||||
...specialization,
|
||||
focuses: specializationFocuses
|
||||
.filter((focus) => focus.specialization_id === specialization.id)
|
||||
.map((focus) => {
|
||||
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))
|
||||
.filter(Boolean);
|
||||
const nodes = specializationNodes
|
||||
.filter((node) => node.focus_id === focus.id)
|
||||
.map((node) => ({
|
||||
...node,
|
||||
ranks: specializationRanks
|
||||
.filter((rank) => rank.node_id === node.id)
|
||||
.map((rank) => ({
|
||||
...rank,
|
||||
effects: specializationRankEffects.filter(
|
||||
(effect) =>
|
||||
effect.node_id === node.id
|
||||
&& Number(effect.rank) === Number(rank.rank)
|
||||
),
|
||||
combatEffects: specializationRankCombatEffects
|
||||
.filter(
|
||||
(effect) =>
|
||||
effect.node_id === node.id
|
||||
&& Number(effect.rank) === Number(rank.rank)
|
||||
)
|
||||
.map((effect) => ({
|
||||
...effect,
|
||||
definition: specializationCombatEffects.find(
|
||||
(definition) => definition.id === effect.combat_effect_id
|
||||
),
|
||||
modifiers: specializationCombatModifiers.filter(
|
||||
(modifier) => modifier.rank_combat_effect_id === effect.id
|
||||
)
|
||||
}))
|
||||
}))
|
||||
}));
|
||||
return { ...focus, abilities, nodes };
|
||||
})
|
||||
})),
|
||||
calculation: {
|
||||
revision: activeCalculationRevision ?? null,
|
||||
stages: calculationStages,
|
||||
profiles: calculationProfiles
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { gameDataIconUrl } from '$lib/server/game-data-icons';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { migrateLoadoutDraft } from '$lib/builder/loadout';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const validVisibility = new Set(['private', 'unlisted', 'public']);
|
||||
@@ -12,11 +13,7 @@ const slugify = (value: string) => value.toLowerCase().normalize('NFKD')
|
||||
function parseLoadout(raw: FormDataEntryValue | null) {
|
||||
if (typeof raw !== 'string' || raw.length > 250_000) return null;
|
||||
try {
|
||||
const value = JSON.parse(raw);
|
||||
if (value?.version !== 1 || typeof value.buildName !== 'string' || !value.gear ||
|
||||
!Array.isArray(value.weapons) || value.weapons.length !== 2 ||
|
||||
!Array.isArray(value.chips) || value.chips.length !== 3 || !value.protocol) return null;
|
||||
return value;
|
||||
return migrateLoadoutDraft(JSON.parse(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -40,7 +37,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
talents, talentTiers, talentEffects, talentTemplates,
|
||||
chipSets, chipTwoPiece, chipPrimary, chipSecondary, chipTalents, chipTalentEffects,
|
||||
protocols, protocolPrimary, protocolProfiles, protocolSecondary, protocolTalents, protocolTalentEffects, coreRanges,
|
||||
icons
|
||||
icons, specializations, specializationFocuses, specializationAbilities, focusAbilities,
|
||||
specializationNodes, specializationRanks, specializationRankEffects, specializationRankCosts,
|
||||
specializationCombatEffects, specializationRankCombatEffects, specializationCombatModifiers,
|
||||
progression, rankProfiles, activeCalculationRevision, calculationStages, calculationProfiles
|
||||
] = 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(),
|
||||
@@ -71,9 +71,29 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
(await database.prepare('SELECT * FROM os_protocol_talents')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_talent_effects ORDER BY sort_order')).all(),
|
||||
(await database.prepare('SELECT * FROM core_attribute_tier_ranges ORDER BY tier')).all(),
|
||||
(await database.prepare("SELECT * FROM game_data_icons")).all()
|
||||
(await database.prepare("SELECT * FROM game_data_icons")).all(),
|
||||
(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_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(),
|
||||
(await database.prepare('SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_tree_rank_costs ORDER BY node_id,rank,resource_id')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_combat_effects WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_tree_rank_combat_effects ORDER BY node_id,rank,effect_order')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_tree_rank_combat_modifiers ORDER BY rank_combat_effect_id,modifier_order')).all(),
|
||||
(await database.prepare("SELECT * FROM specialization_progression_config WHERE id='default'")).get(),
|
||||
(await database.prepare('SELECT * FROM specialization_rank_cost_profiles ORDER BY rank')).all(),
|
||||
(await database.prepare("SELECT * FROM calculation_revisions WHERE status='active' ORDER BY version DESC LIMIT 1")).get(),
|
||||
(await database.prepare(`SELECT stage.* FROM calculation_stages stage
|
||||
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()
|
||||
]);
|
||||
const iconUrl = (type: 'talent' | 'brand_set' | 'os_protocol' | 'skill_chip', id: unknown) => {
|
||||
const iconUrl = (type: 'talent' | 'brand_set' | 'os_protocol' | 'skill_chip' | 'specialization' | 'specialization_ability' | 'specialization_node', id: unknown) => {
|
||||
const icon = icons.find((item) => item.entity_type === type && item.entity_id === id);
|
||||
return icon ? gameDataIconUrl(type, String(id), icon.updated_at) : '';
|
||||
};
|
||||
@@ -134,7 +154,50 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
secondaryEffects: profile ? protocolSecondary.filter((effect) => effect.profile_id === profile.id) : [],
|
||||
talent: talent ? { ...talent, effects: protocolTalentEffects.filter((effect) => effect.talent_id === talent.id) } : null
|
||||
};
|
||||
}),
|
||||
specializations: specializations.map((specialization) => ({
|
||||
...specialization,
|
||||
iconUrl: iconUrl('specialization', specialization.id),
|
||||
focuses: specializationFocuses.filter((focus) => focus.specialization_id === specialization.id).map((focus) => {
|
||||
const assignedAbilities = focusAbilities.filter((link) => link.focus_id === focus.id)
|
||||
.sort((left, right) => Number(left.ability_slot) - Number(right.ability_slot))
|
||||
.map((link) => {
|
||||
const ability = specializationAbilities.find((item) => item.id === link.ability_id);
|
||||
return ability ? {
|
||||
...ability,
|
||||
slot: Number(link.ability_slot),
|
||||
iconUrl: iconUrl('specialization_ability', ability.id)
|
||||
} : null;
|
||||
}).filter(Boolean);
|
||||
const nodes = specializationNodes.filter((node) => node.focus_id === focus.id).map((node) => ({
|
||||
...node,
|
||||
iconUrl: iconUrl('specialization_node', node.id),
|
||||
ranks: specializationRanks.filter((rank) => rank.node_id === node.id).map((rank) => ({
|
||||
...rank,
|
||||
effects: specializationRankEffects.filter((effect) => effect.node_id === node.id && Number(effect.rank) === Number(rank.rank)),
|
||||
costs: specializationRankCosts.filter((cost) => cost.node_id === node.id && Number(cost.rank) === Number(rank.rank)),
|
||||
combatEffects: specializationRankCombatEffects
|
||||
.filter((effect) => effect.node_id === node.id && Number(effect.rank) === Number(rank.rank))
|
||||
.map((effect) => ({
|
||||
...effect,
|
||||
definition: specializationCombatEffects.find((definition) => definition.id === effect.combat_effect_id),
|
||||
modifiers: specializationCombatModifiers.filter((modifier) => modifier.rank_combat_effect_id === effect.id)
|
||||
}))
|
||||
}))
|
||||
}));
|
||||
return { ...focus, abilities: assignedAbilities, nodes };
|
||||
})
|
||||
})),
|
||||
specializationProgression: {
|
||||
...progression,
|
||||
availablePoints: Number(progression?.xp_points ?? 30) + Number(progression?.shd_cache_points ?? 19),
|
||||
rankProfiles
|
||||
},
|
||||
calculation: {
|
||||
revision: activeCalculationRevision,
|
||||
stages: calculationStages,
|
||||
profiles: calculationProfiles
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+166
-114
@@ -7,14 +7,19 @@
|
||||
import RangeValueControl from '$lib/components/RangeValueControl.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||
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 { toast } from '$lib/toast';
|
||||
import {
|
||||
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, remapRangeValue, scaled, weaponBaseRange,
|
||||
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, migrateLoadoutDraft, remapRangeValue, scaled, weaponBaseRange,
|
||||
type LoadoutDraft, type Roll
|
||||
} from '$lib/builder/loadout';
|
||||
|
||||
let { data, form } = $props();
|
||||
let draft = $state<LoadoutDraft>(untrack(() => data.initialBuild?.loadout ?? createDraft()));
|
||||
let draft = $state<LoadoutDraft>(untrack(() => migrateLoadoutDraft(data.initialBuild?.loadout) ?? createDraft()));
|
||||
let active = $state('gear:Holster');
|
||||
let saved = $state(false);
|
||||
let message = $state('');
|
||||
@@ -32,6 +37,23 @@
|
||||
const weaponItem = (index: number) => data.weapons.find((item) => item.id === draft.weapons[index].itemId);
|
||||
const chipItem = (index: number) => data.chipSets.find((item) => item.id === draft.chips[index].setId);
|
||||
const protocolItem = () => data.protocols.find((item) => item.id === draft.protocol.itemId);
|
||||
const allFocuses = $derived(data.specializations.flatMap((specialization: any) =>
|
||||
specialization.focuses.map((focus: any) => ({ ...focus, specialization }))
|
||||
));
|
||||
const selectedFocus = $derived(allFocuses.find((focus: any) => focus.id === draft.specialization.focusId));
|
||||
const specializationItems = $derived(allFocuses.map((focus: any) => ({
|
||||
id: focus.id,
|
||||
name: `${focus.specialization.name} · ${focus.name}`,
|
||||
category: `${focus.primary_attribute} · ${Number(focus.builder_ready) === 1 ? 'BUILDER READY' : 'INCOMPLETE DATA'}`,
|
||||
iconUrl: focus.specialization.iconUrl
|
||||
})));
|
||||
const selectedNodeRanks = $derived(selectedFocus?.nodes.flatMap((node: any) => {
|
||||
const selectedRank = Number(draft.specialization.nodeRanks[node.id] ?? 0);
|
||||
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 }))
|
||||
));
|
||||
const talent = (id: string) => data.talents.find((item) => item.id === id);
|
||||
const brand = (id: string) => data.brands.find((item) => item.id === id);
|
||||
const unitValue = (value: unknown, statId: string) => {
|
||||
@@ -40,6 +62,19 @@
|
||||
const unit = attribute(statId)?.unit ?? '';
|
||||
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
|
||||
};
|
||||
const combatEffectDescription = (effect: any) => {
|
||||
const template = String(effect.definition?.description_template ?? effect.definition?.name ?? 'Conditional effect');
|
||||
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('{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 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) =>
|
||||
@@ -65,6 +100,73 @@
|
||||
: attributeRange(stat, source, level).min;
|
||||
roll.value = Number(min ?? 0);
|
||||
};
|
||||
const rankPointCost = (rank: number) => Number(data.specializationProgression.rankProfiles.find((profile: any) => Number(profile.rank) === rank)?.point_cost ?? rank);
|
||||
const selectedSpecializationPoints = $derived(selectedFocus?.nodes.reduce((total: number, node: any) => {
|
||||
const selectedRank = Number(draft.specialization.nodeRanks[node.id] ?? 0);
|
||||
return total + Array.from({ length: selectedRank }, (_, index) => rankPointCost(index + 1)).reduce((sum, value) => sum + value, 0);
|
||||
}, 0) ?? 0);
|
||||
const selectedSpecializationCosts = $derived.by(() => {
|
||||
let credits = 0;
|
||||
const resources = new Map<string, number>();
|
||||
for (const { node, rank } of selectedNodeRanks) {
|
||||
for (let current = 1; current <= Number(rank.rank); current += 1) {
|
||||
const profile = data.specializationProgression.rankProfiles.find((item: any) => Number(item.rank) === current);
|
||||
credits += Number(profile?.credit_cost ?? 0);
|
||||
const rankRecord = node.ranks.find((item: any) => Number(item.rank) === current);
|
||||
for (const cost of rankRecord?.costs ?? []) resources.set(String(cost.resource_id), (resources.get(String(cost.resource_id)) ?? 0) + Number(cost.quantity));
|
||||
}
|
||||
}
|
||||
return { credits, resources: [...resources] };
|
||||
});
|
||||
const canSelectNodeRank = (node: any, rank: number) => {
|
||||
if (rank === 0) return true;
|
||||
const pointsWithoutNode = selectedSpecializationPoints - Array.from(
|
||||
{ length: Number(draft.specialization.nodeRanks[node.id] ?? 0) },
|
||||
(_, index) => rankPointCost(index + 1)
|
||||
).reduce((sum, value) => sum + value, 0);
|
||||
const candidatePoints = Array.from({ length: rank }, (_, index) => rankPointCost(index + 1)).reduce((sum, value) => sum + value, 0);
|
||||
if (pointsWithoutNode + candidatePoints > Number(data.specializationProgression.availablePoints)) return false;
|
||||
if (Number(node.row_number) <= 1) return true;
|
||||
return selectedFocus.nodes
|
||||
.filter((previous: any) => Number(previous.row_number) < Number(node.row_number))
|
||||
.every((previous: any) => Number(draft.specialization.nodeRanks[previous.id] ?? 0) >= Math.min(2, Number(previous.max_rank)));
|
||||
};
|
||||
const setNodeRank = (node: any, rank: number) => {
|
||||
if (!canSelectNodeRank(node, rank)) return;
|
||||
draft.specialization.nodeRanks = { ...draft.specialization.nodeRanks, [node.id]: rank };
|
||||
};
|
||||
const selectFocus = (focusId: string) => {
|
||||
const next = allFocuses.find((focus: any) => focus.id === focusId);
|
||||
if (!next) return;
|
||||
const unassigned = [...draft.specialization.unassignedChips];
|
||||
for (let index = 0; index < draft.chips.length; index += 1) {
|
||||
const chip = draft.chips[index];
|
||||
const ability = next.abilities[Math.floor(index / 3)];
|
||||
const definition = data.chipSets.find((item: any) => item.id === chip.setId);
|
||||
if (chip.setId && (!ability || definition?.specialization_ability_id !== ability.id)) {
|
||||
unassigned.push(structuredClone(chip));
|
||||
draft.chips[index] = createDraft().chips[index];
|
||||
}
|
||||
}
|
||||
for (let index = unassigned.length - 1; index >= 0; index -= 1) {
|
||||
const chip = unassigned[index];
|
||||
const definition = data.chipSets.find((item: any) => item.id === chip.setId);
|
||||
const abilityIndex = next.abilities.findIndex((ability: any) => ability.id === definition?.specialization_ability_id);
|
||||
if (abilityIndex < 0) continue;
|
||||
const targetIndex = [0, 1, 2].map((slot) => abilityIndex * 3 + slot).find((slot) => !draft.chips[slot].setId);
|
||||
if (targetIndex === undefined) continue;
|
||||
draft.chips[targetIndex] = structuredClone(chip);
|
||||
unassigned.splice(index, 1);
|
||||
}
|
||||
draft.specialization = {
|
||||
...draft.specialization,
|
||||
focusId,
|
||||
nodeRanks: {},
|
||||
effectStates: {},
|
||||
unassignedChips: unassigned
|
||||
};
|
||||
active = 'specialization:focus';
|
||||
};
|
||||
const toggleTalent = (key: string) => {
|
||||
draft.activeTalents = draft.activeTalents.includes(key)
|
||||
? draft.activeTalents.filter((item) => item !== key)
|
||||
@@ -189,114 +291,24 @@
|
||||
if (value === null || value === undefined || value === '') return '';
|
||||
return `Talent Cooldown: ${value} ${Number(value) === 1 ? 'second' : 'seconds'}`;
|
||||
};
|
||||
const summary = $derived.by(() => {
|
||||
const values = new Map<string, number>();
|
||||
const sources = new Map<string, Set<string>>();
|
||||
const add = (statId: string, value: unknown, source: string) => {
|
||||
if (!statId || !Number.isFinite(Number(value))) return;
|
||||
const stat = attribute(statId), current = values.get(statId) ?? 0, incoming = Number(value);
|
||||
const combined = stat?.calculation_type === 'Multiplicative' && stat?.unit === '%'
|
||||
? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100
|
||||
: current + incoming;
|
||||
values.set(statId, stat?.cap_value === null || stat?.cap_value === undefined ? combined : Math.min(combined, Number(stat.cap_value)));
|
||||
if (!sources.has(statId)) sources.set(statId, new Set());
|
||||
sources.get(statId)!.add(source);
|
||||
};
|
||||
const byName = (name: string) => data.attributes.find((item) => item.name.toLocaleLowerCase() === name.toLocaleLowerCase())?.id ?? '';
|
||||
const brandCounts = new Map<string, number>();
|
||||
const activeBonuses: any[] = [], equippedTalents: any[] = [];
|
||||
for (const slot of gearSlots) {
|
||||
const selection = draft.gear[slot], item = gearItem(slot);
|
||||
if (!item) continue;
|
||||
const enabledAugments = data.gearAugments.filter((entry) => entry.gear_slot === slot && selection.augmentSlots.includes(Number(entry.augment_slot)));
|
||||
const basePercentage = enabledAugments.filter((entry) => entry.effect_type === 'Base Percentage').reduce((total, entry) => total + Number(entry.value), 0);
|
||||
add(byName('Health'), selection.health * (1 + basePercentage / 100), `${slot} base`);
|
||||
if (item.base_armor_min !== null) add(byName('Armor'), selection.armor * (1 + basePercentage / 100), `${slot} base`);
|
||||
for (const roll of selection.rolls) add(roll.statId, roll.value, `${slot} roll`);
|
||||
for (const key of coreKeys) add(byName(key), selection[key], `${slot} core`);
|
||||
if (selection.brandId) brandCounts.set(selection.brandId, (brandCounts.get(selection.brandId) ?? 0) + 1);
|
||||
for (const augment of enabledAugments.filter((entry) => entry.effect_type === 'Custom')) {
|
||||
add(augment.stat_attribute_id, augment.value, `${slot} augment ${augment.augment_slot}`);
|
||||
}
|
||||
if (selection.talentId) equippedTalents.push({ key: `gear:${slot}:${selection.talentId}`, id: selection.talentId, tier: Number(item.tier), source: slot, description: talentDescription(selection.talentId, Number(item.tier)) });
|
||||
}
|
||||
for (const [brandId, count] of brandCounts) {
|
||||
const set = brand(brandId);
|
||||
for (const effect of set?.effects.filter((entry: any) => Number(entry.pieces_required) <= count) ?? []) {
|
||||
add(effect.stat_attribute_id, effect.value, `${set.name} ${effect.pieces_required}-piece`);
|
||||
activeBonuses.push({ name: set.name, pieces: effect.pieces_required, count, statId: effect.stat_attribute_id, value: effect.value });
|
||||
}
|
||||
}
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const selection = draft.weapons[index], item = weaponItem(index);
|
||||
if (!item) continue;
|
||||
for (const name of ['Damage', 'Firepower', 'Toughness', 'Engineering'] as const) add(byName(name), selection[name.toLowerCase() as 'damage'], `${index ? 'Secondary' : 'Primary'} weapon`);
|
||||
for (const bonus of item.baseBonuses) add(bonus.stat_attribute_id, bonus.value, `${item.name} base bonus`);
|
||||
for (const roll of selection.rolls) add(roll.statId, roll.value, `${index ? 'Secondary' : 'Primary'} weapon roll`);
|
||||
for (const talentId of [selection.talent1Id, selection.talent2Id, item.exotic_talent_id].filter(Boolean)) equippedTalents.push({
|
||||
key: `weapon:${index}:${talentId}`, id: talentId, tier: selection.tier, source: index ? 'Secondary weapon' : 'Primary weapon', description: talentDescription(talentId, selection.tier)
|
||||
});
|
||||
}
|
||||
const chipGroups = new Map<string, { count: number; highEnd: number; item: any }>();
|
||||
for (let index = 0; index < 3; index++) {
|
||||
const chip = draft.chips[index], item = chipItem(index);
|
||||
if (!item) continue;
|
||||
add(chip.primary.statId, chip.primary.value, `Skill Chip ${index + 1} primary`);
|
||||
for (const roll of chip.secondary) add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
|
||||
const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item };
|
||||
group.count += 1;
|
||||
if (chip.quality === 'High-end') group.highEnd += 1;
|
||||
chipGroups.set(item.id, group);
|
||||
}
|
||||
for (const group of chipGroups.values()) {
|
||||
if (group.count >= 2 && group.item.twoPiece) {
|
||||
const value = scaled(group.item.twoPiece.min_value, group.item.twoPiece.max_value, group.highEnd, 3);
|
||||
add(group.item.twoPiece.stat_attribute_id, value, `${group.item.name} 2-piece`);
|
||||
activeBonuses.push({ name: group.item.name, pieces: 2, count: group.count, statId: group.item.twoPiece.stat_attribute_id, value });
|
||||
}
|
||||
if (group.count === 3 && group.item.talent) equippedTalents.push({
|
||||
key: `chips:${group.item.id}`, id: '', custom: group.item.talent, tier: group.highEnd, 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];
|
||||
return effect ? unitValue(scaled(effect.min_value, effect.max_value, group.highEnd, 3), effect.stat_attribute_id) : placeholder;
|
||||
})
|
||||
});
|
||||
}
|
||||
const protocol = protocolItem();
|
||||
if (protocol) {
|
||||
if (protocol.primaryEffect) add(protocol.primaryEffect.stat_attribute_id, Number(protocol.primaryEffect.base_value) * (1 + (draft.protocol.level - 1) / 49), `${protocol.name} primary`);
|
||||
for (const effect of protocol.secondaryEffects) add(effect.stat_attribute_id, effect.value, `${protocol.name} secondary`);
|
||||
if (protocol.talent) equippedTalents.push({
|
||||
key: `protocol:${protocol.id}`, id: '', custom: protocol.talent, tier: draft.protocol.talentUpgrades, source: 'OS Protocol',
|
||||
description: protocol.talent.description_template.replace(/\{(\d+)\}/g, (placeholder: string, raw: string) => {
|
||||
const effect = protocol.talent.effects[Number(raw) - 1];
|
||||
return effect ? unitValue(Number(effect.base_value) * (1 + draft.protocol.talentUpgrades / 5), effect.stat_attribute_id) : placeholder;
|
||||
})
|
||||
});
|
||||
}
|
||||
for (const equipped of equippedTalents) {
|
||||
if (!draft.activeTalents.includes(equipped.key)) continue;
|
||||
const effects = equipped.custom?.effects ?? talentTier(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 })).sort((a, b) => (attribute(a.id)?.category ?? '').localeCompare(attribute(b.id)?.category ?? '') || (attribute(a.id)?.name ?? '').localeCompare(attribute(b.id)?.name ?? '')),
|
||||
activeBonuses,
|
||||
equippedTalents
|
||||
};
|
||||
});
|
||||
const summary = $derived(summarizeLoadout(draft, data));
|
||||
const combatResults = $derived(evaluateLoadoutCombat(draft, data, summary));
|
||||
|
||||
$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' }]))
|
||||
};
|
||||
});
|
||||
onMount(() => {
|
||||
if (data.initialBuild) return;
|
||||
const stored = localStorage.getItem('shd-build-loadout-draft-v1');
|
||||
const stored = localStorage.getItem('shd-build-loadout-draft-v2') ?? localStorage.getItem('shd-build-loadout-draft-v1');
|
||||
if (!stored) return;
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed?.version === 1) {
|
||||
const parsed = migrateLoadoutDraft(JSON.parse(stored));
|
||||
if (parsed) {
|
||||
draft = parsed;
|
||||
for (const slot of gearSlots) {
|
||||
if (!Number.isInteger(draft.gear[slot].level)) draft.gear[slot].level = 50;
|
||||
@@ -311,7 +323,10 @@
|
||||
if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = '';
|
||||
saved = true;
|
||||
}
|
||||
} catch { localStorage.removeItem('shd-build-loadout-draft-v1'); }
|
||||
} catch {
|
||||
localStorage.removeItem('shd-build-loadout-draft-v2');
|
||||
localStorage.removeItem('shd-build-loadout-draft-v1');
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
if (!form?.buildSaved || !form?.buildToken || form.buildToken === handledBuildToken) return;
|
||||
@@ -326,7 +341,8 @@
|
||||
: 'Build published to the SHD archive.';
|
||||
});
|
||||
function saveDraft() {
|
||||
localStorage.setItem('shd-build-loadout-draft-v1', JSON.stringify(draft));
|
||||
localStorage.setItem('shd-build-loadout-draft-v2', JSON.stringify(draft));
|
||||
localStorage.removeItem('shd-build-loadout-draft-v1');
|
||||
saved = true;
|
||||
toast.success('Draft saved locally', 'This loadout is available on this device.');
|
||||
}
|
||||
@@ -364,21 +380,55 @@
|
||||
{#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>12 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>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}
|
||||
{#if !data.gear.length && !data.weapons.length && !data.chipSets.length && !data.protocols.length}<div class="builder-empty-data"><strong>ISAC DATA LINK EMPTY</strong><span>Add active equipment definitions in ISAC to populate this workbench.</span></div>{/if}
|
||||
<div class="loadout-workspace">
|
||||
<section class="loadout-slots">
|
||||
<header><span>EQUIPMENT</span><b>{gearSlots.filter((slot) => draft.gear[slot].itemId).length + draft.weapons.filter((item) => item.itemId).length + draft.chips.filter((item) => item.setId).length + (draft.protocol.itemId ? 1 : 0)} / 12 EQUIPPED</b></header>
|
||||
<header><span>EQUIPMENT</span><b>{gearSlots.filter((slot) => draft.gear[slot].itemId).length + draft.weapons.filter((item) => item.itemId).length + draft.chips.filter((item) => item.setId).length + (draft.protocol.itemId ? 1 : 0)} / 18 EQUIPPED</b></header>
|
||||
<div class="loadout-slot-group weapons">{#each ['PRIMARY', 'SECONDARY'] as label, index}{@const item = weaponItem(index)}<button class:active={active === `weapon:${index}`} onclick={() => active = `weapon:${index}`}><small>{label} WEAPON</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${item.category}${Number(item.is_exotic) ? ' · EXOTIC' : ''}` : 'SELECT WEAPON'}</span></button>{/each}</div>
|
||||
<div class="loadout-slot-group gear">{#each gearSlots as slot}{@const item = gearItem(slot)}<button class:active={active === `gear:${slot}`} onclick={() => active = `gear:${slot}`}><small>{slot}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? brand(draft.gear[slot].brandId)?.name ?? 'CHOOSE BRAND' : 'SELECT GEAR'}</span></button>{/each}</div>
|
||||
<div class="loadout-slot-group systems">{#each [0,1,2] as index}{@const item = chipItem(index)}<button class:active={active === `chip:${index}`} onclick={() => active = `chip:${index}`}><small>SKILL CHIP 0{index + 1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? draft.chips[index].quality : 'SELECT CHIP'}</span></button>{/each}<button class:active={active === 'protocol:0'} onclick={() => active = 'protocol:0'}><small>OS PROTOCOL</small><strong>{protocolItem()?.name ?? 'EMPTY SLOT'}</strong><span>{protocolItem() ? `LEVEL ${draft.protocol.level}` : 'SELECT PROTOCOL'}</span></button></div>
|
||||
<div class="loadout-slot-group systems"><button class:active={activeParts[0] === 'specialization' || activeParts[0] === 'chip'} onclick={() => active = 'specialization:focus'}><small>SPECIALIZATION FOCUS</small><strong>{selectedFocus ? `${selectedFocus.specialization.name} · ${selectedFocus.name}` : 'NOT CONFIGURED'}</strong><span>{selectedFocus ? `${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS · ${draft.chips.filter((chip) => chip.setId).length} / 9 CHIPS` : 'SELECT FOCUS'}</span></button><button class:active={active === 'protocol:0'} onclick={() => active = 'protocol:0'}><small>OS PROTOCOL</small><strong>{protocolItem()?.name ?? 'EMPTY SLOT'}</strong><span>{protocolItem() ? `LEVEL ${draft.protocol.level}` : 'SELECT PROTOCOL'}</span></button></div>
|
||||
</section>
|
||||
|
||||
<section class="loadout-editor">
|
||||
{#if activeParts[0] === 'gear'}
|
||||
{#if activeParts[0] === 'specialization'}
|
||||
<div class="loadout-editor-head"><div><span>AGENT CLASS</span><h2>SPECIALIZATION FOCUS</h2></div><b>{selectedFocus ? `${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS` : 'UNASSIGNED'}</b></div>
|
||||
<BuilderPicker items={specializationItems} value={draft.specialization.focusId} onchange={selectFocus} label="SPECIALIZATION + FOCUS"/>
|
||||
{#if draft.specialization.unassignedChips.length}
|
||||
<div class="builder-remediation" role="status"><strong>UNRESOLVED LEGACY SKILL CHIPS</strong><span>{draft.specialization.unassignedChips.length} chip selection{draft.specialization.unassignedChips.length === 1 ? '' : 's'} from the previous builder could not be matched to this focus. The original data remains in this draft.</span></div>
|
||||
{/if}
|
||||
{#if selectedFocus}
|
||||
<div class="builder-specialization-identity"><span class="focus-composite-icon"><ItemIcon src={selectedFocus.specialization.iconUrl} alt={selectedFocus.specialization.name}/><GameIcon name={selectedFocus.primary_attribute.toLocaleLowerCase() as GameIconName} decorative/></span><div><small>{selectedFocus.specialization.name}</small><h3>{selectedFocus.name}</h3><p>{selectedFocus.description || 'No focus description has been entered.'}</p></div><b class:configured={Number(selectedFocus.builder_ready) === 1}>{Number(selectedFocus.builder_ready) === 1 ? 'BUILDER READY' : 'ISAC DATA INCOMPLETE'}</b></div>
|
||||
<div class="builder-ability-chip-panels">
|
||||
{#each [0,1,2] as abilityIndex}
|
||||
{@const ability = selectedFocus.abilities[abilityIndex]}
|
||||
<section>
|
||||
<header><ItemIcon src={ability?.iconUrl} alt={ability?.name ?? `Ability ${abilityIndex + 1}`}/><div><small>ABILITY {abilityIndex + 1}</small><strong>{ability?.name ?? 'NOT CONFIGURED'}</strong></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3 CHIPS</b></header>
|
||||
<p>{ability?.description ?? 'ISAC requires this class ability before compatible Skill Chips can be equipped.'}</p>
|
||||
<div>{#each [0,1,2] as chipSlot}{@const chipIndex = abilityIndex * 3 + chipSlot}{@const item = chipItem(chipIndex)}<button type="button" onclick={() => active = `chip:${chipIndex}`}><small>CHIP {chipSlot + 1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? draft.chips[chipIndex].quality : 'SELECT CHIP'}</span></button>{/each}</div>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
<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>
|
||||
{#each [...new Set(selectedFocus.nodes.map((node: any) => Number(node.row_number)))] as row}
|
||||
<section><header><span>ROW {String(row).padStart(2, '0')}</span><b>{row === 1 ? 'ENTRY ROW' : 'LOCKED BY PREVIOUS ROWS'}</b></header><div>
|
||||
{#each selectedFocus.nodes.filter((node: any) => Number(node.row_number) === row) as node}
|
||||
<article><ItemIcon src={node.iconUrl} alt={node.name}/><div><strong>{node.name}</strong><small>{node.description_template}</small></div><SegmentedChoice legend={`${node.name} rank`} name={`specialization-node-${node.id}`} options={[{value:'0',label:'OFF'},...Array.from({length:Number(node.max_rank)},(_,index)=>({value:String(index+1),label:`R${index+1}`,className:canSelectNodeRank(node,index+1)?'':'unavailable',disabled:!canSelectNodeRank(node,index+1)}))]} value={String(draft.specialization.nodeRanks[node.id] ?? 0)} onchange={(values) => setNodeRank(node,Number(values[0]))} showIndicator={false}/></article>
|
||||
{/each}
|
||||
</div></section>
|
||||
{/each}
|
||||
</div>
|
||||
<CombatAssumptions bind:profile={draft.specialization.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>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else if activeParts[0] === 'gear'}
|
||||
{@const slot = activeParts[1]}{@const selection = draft.gear[slot]}{@const item = gearItem(slot)}
|
||||
<div class="loadout-editor-head"><div><span>GEAR SLOT</span><h2>{slot.toUpperCase()}</h2></div><b>{item ? `TIER ${item.tier} · BUILD LEVEL ${selection.level}` : 'UNASSIGNED'}</b></div>
|
||||
<BuilderPicker items={data.gear.filter((entry) => entry.slot === slot)} bind:value={selection.itemId} onchange={(id) => configureGear(slot,id)} label="ITEM DEFINITION"/>
|
||||
@@ -429,9 +479,10 @@
|
||||
{#if item.baseBonuses.length}<div class="builder-inherited">{#each item.baseBonuses as bonus}<span><small>{attribute(bonus.stat_attribute_id)?.name}</small><b>{unitValue(bonus.value,bonus.stat_attribute_id)}</b></span>{/each}</div>{/if}
|
||||
{/if}
|
||||
{:else if activeParts[0] === 'chip'}
|
||||
{@const index = Number(activeParts[1])}{@const chip = draft.chips[index]}{@const item = chipItem(index)}
|
||||
<div class="loadout-editor-head"><div><span>SKILL SYSTEM</span><h2>SKILL CHIP 0{index + 1}</h2></div><b>{chip.quality.toUpperCase()}</b></div>
|
||||
<BuilderPicker items={data.chipSets} bind:value={chip.setId} onchange={(id) => configureChip(index,id)} label="SKILL CHIP SET"/>
|
||||
{@const index = Number(activeParts[1])}{@const chip = draft.chips[index]}{@const item = chipItem(index)}{@const abilityIndex = Math.floor(index / 3)}{@const ability = selectedFocus?.abilities[abilityIndex]}
|
||||
<div class="loadout-editor-head"><div><span>ABILITY {abilityIndex + 1} · CHIP {(index % 3) + 1}</span><h2>{ability?.name?.toUpperCase() ?? 'SKILL CHIP'}</h2></div><b>{chip.quality.toUpperCase()}</b></div>
|
||||
<button class="builder-return-link" type="button" onclick={() => active = 'specialization:focus'}>← RETURN TO SPECIALIZATION</button>
|
||||
{#if ability}<BuilderPicker items={data.chipSets.filter((entry: any) => entry.specialization_ability_id === ability.id)} bind:value={chip.setId} onchange={(id) => configureChip(index,id)} label="ABILITY-COMPATIBLE SKILL CHIP SET"/>{:else}<p class="error">Select a focus with three configured class abilities before equipping Skill Chips.</p>{/if}
|
||||
{#if item}<div class="builder-field-grid"><label>QUALITY<select bind:value={chip.quality}><option>Superior</option><option>High-end</option></select></label></div><div class="builder-chip-rolls"><div><BuilderPicker items={chipAttributes.filter((entry) => item.primaryStatIds.includes(entry.id))} bind:value={chip.primary.statId} onchange={(id) => setRollStat(chip.primary,id,'chip')} label="PRIMARY ATTRIBUTE"/><label>VALUE<input type="number" min={attribute(chip.primary.statId)?.skill_chip_min ?? 0} max={attribute(chip.primary.statId)?.skill_chip_max ?? 0} bind:value={chip.primary.value} onblur={() => chip.primary.value = clamp(chip.primary.value,attribute(chip.primary.statId)?.skill_chip_min,attribute(chip.primary.statId)?.skill_chip_max)}/></label></div>{#each chip.secondary as roll,index}<div><BuilderPicker items={chipAttributes.filter((entry) => item.secondaryStatIds.includes(entry.id))} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'chip')} disabledIds={chip.secondary.filter((_,i) => i !== index).map((entry) => entry.statId)} label={`SECONDARY ATTRIBUTE ${index + 1}`}/><label>VALUE<input type="number" min={attribute(roll.statId)?.skill_chip_min ?? 0} max={attribute(roll.statId)?.skill_chip_max ?? 0} bind:value={roll.value} onblur={() => roll.value = clamp(roll.value,attribute(roll.statId)?.skill_chip_min,attribute(roll.statId)?.skill_chip_max)}/></label></div>{/each}</div>{/if}
|
||||
{:else}
|
||||
{@const protocol = protocolItem()}
|
||||
@@ -444,6 +495,7 @@
|
||||
<aside class="loadout-summary">
|
||||
<div class="summary-head"><div><p class="eyebrow"><span></span> LIVE SUMMARY</p><h2>AGENT STATS</h2></div><b>{summary.stats.length}</b></div>
|
||||
<div class="summary-core">{#each ['Firepower','Toughness','Engineering'] as name}{@const stat = summary.stats.find((entry) => attribute(entry.id)?.name === name)}<div><GameIcon name={name.toLowerCase() as GameIconName} decorative/><span>{name}<b>{stat ? unitValue(stat.value,stat.id) : '0'}</b></span></div>{/each}</div>
|
||||
<div class="builder-combat-results">{#each combatResults as result}<WeaponCalculationResult {result}/>{/each}</div>
|
||||
<div class="summary-scroll">
|
||||
<div class="summary-section"><header><span>COMBINED ATTRIBUTES</span><b>{summary.stats.length}</b></header>{#each summary.stats as stat}<div class="summary-stat"><span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span><b>{unitValue(stat.value,stat.id)}</b></div>{:else}<p>Equip and configure items to calculate attributes.</p>{/each}</div>
|
||||
<div class="summary-section"><header><span>ACTIVE SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>{#each summary.activeBonuses as bonus}<div class="summary-bonus"><span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span><em>+{unitValue(bonus.value,bonus.statId)} {attribute(bonus.statId)?.name}</em></div>{:else}<p>Matching Brand Sets and Skill Chips activate bonuses here.</p>{/each}</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { migrateLoadoutDraft, type LoadoutDraft } from '$lib/builder/loadout';
|
||||
import { evaluateLoadoutCombat, summarizeLoadout } from '$lib/builder/loadout-combat';
|
||||
import { loadSharedBuildCombatCatalog } from '$lib/server/shared-build-combat';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
@@ -14,21 +17,29 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
WHERE builds.slug=?
|
||||
`)).get(params.slug);
|
||||
if (!build || (build.visibility === 'private' && build.owner_id !== viewer?.id)) error(404, 'Build not found');
|
||||
let loadout;
|
||||
try { loadout = JSON.parse(String(build.loadout_json)); } catch { error(404, 'Build data is unavailable'); }
|
||||
const [gear, weapons, chips, protocols, brands] = await Promise.all([
|
||||
let loadout: LoadoutDraft;
|
||||
try {
|
||||
const migrated = migrateLoadoutDraft(JSON.parse(String(build.loadout_json)));
|
||||
if (!migrated) error(404, 'Build data is unavailable');
|
||||
loadout = migrated;
|
||||
} catch {
|
||||
error(404, 'Build data is unavailable');
|
||||
}
|
||||
const [gear, weapons, chips, protocols, brands, combatCatalog] = await Promise.all([
|
||||
(await database.prepare('SELECT id,name,slot,tier FROM gear')).all(),
|
||||
(await database.prepare('SELECT id,name,category,is_exotic FROM weapons')).all(),
|
||||
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
|
||||
(await database.prepare('SELECT id,name,quality FROM os_protocols')).all(),
|
||||
(await database.prepare('SELECT id,name FROM brand_sets')).all()
|
||||
(await database.prepare('SELECT id,name FROM brand_sets')).all(),
|
||||
loadSharedBuildCombatCatalog(database)
|
||||
]);
|
||||
const combatResults = evaluateLoadoutCombat(loadout, combatCatalog, summarizeLoadout(loadout, combatCatalog));
|
||||
return {
|
||||
build: {
|
||||
id: String(build.id), title: String(build.title), slug: String(build.slug),
|
||||
visibility: String(build.visibility), author: String(build.author),
|
||||
owner: build.owner_id === viewer?.id, updatedAt: String(build.updated_at)
|
||||
},
|
||||
loadout, gear, weapons, chips, protocols, brands
|
||||
loadout, gear, weapons, chips, protocols, brands, combatResults
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { gearSlots } from '$lib/builder/loadout';
|
||||
import WeaponCalculationResult from '$lib/components/builder/WeaponCalculationResult.svelte';
|
||||
let { data } = $props();
|
||||
const gear = (id: string) => data.gear.find((item) => item.id === id);
|
||||
const weapon = (id: string) => data.weapons.find((item) => item.id === id);
|
||||
@@ -14,6 +15,18 @@
|
||||
<div><p class="eyebrow"><span></span> SHD LOADOUT ARCHIVE / {data.build.visibility.toUpperCase()}</p><h1>{data.build.title}</h1><p>Filed by <strong>{data.build.author}</strong> · Updated {new Date(data.build.updatedAt).toLocaleDateString()}</p></div>
|
||||
<div>{#if data.build.owner}<a class="secondary" href={`/builder?build=${data.build.slug}`}>EDIT LOADOUT</a>{/if}<a class="primary" href="/builder">NEW BUILD +</a></div>
|
||||
</header>
|
||||
<section class="builder-combat-results" aria-labelledby="shared-combat-heading">
|
||||
<div class="builder-section-title">
|
||||
<div>
|
||||
<span>LIVE COMBAT MODEL</span>
|
||||
<h2 id="shared-combat-heading">CURRENT WEAPON OUTPUT</h2>
|
||||
</div>
|
||||
<b>CURRENT ISAC RULES</b>
|
||||
</div>
|
||||
{#each data.combatResults as result}
|
||||
<WeaponCalculationResult {result}/>
|
||||
{/each}
|
||||
</section>
|
||||
<div class="shared-build-grid">
|
||||
<section>
|
||||
<header><span>WEAPONS</span><b>02 SLOTS</b></header>
|
||||
@@ -24,7 +37,7 @@
|
||||
{#each gearSlots as slot}{@const selection=data.loadout.gear[slot]}{@const item=gear(selection.itemId)}<article><small>{slot.toUpperCase()}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${brand(selection.brandId)?.name ?? 'NO BRAND'} · T${item.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span></article>{/each}
|
||||
</section>
|
||||
<section>
|
||||
<header><span>AGENT SYSTEMS</span><b>04 SLOTS</b></header>
|
||||
<header><span>AGENT SYSTEMS</span><b>10 SLOTS</b></header>
|
||||
{#each data.loadout.chips as selection, index}{@const item=chip(selection.setId)}<article><small>SKILL CHIP 0{index+1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? selection.quality : 'NOT CONFIGURED'}</span></article>{/each}
|
||||
{#if protocol(data.loadout.protocol.itemId)}{@const protocolItem=protocol(data.loadout.protocol.itemId)}<article><small>OS PROTOCOL</small><strong>{protocolItem.name}</strong><span>LEVEL {data.loadout.protocol.level} · {data.loadout.protocol.talentUpgrades}/5 UPGRADES</span></article>{:else}<article><small>OS PROTOCOL</small><strong>EMPTY SLOT</strong><span>NOT CONFIGURED</span></article>{/if}
|
||||
</section>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
let readOnlyBlocked = $state(false);
|
||||
const dataSections = [
|
||||
{ href: '/isac/configuration', label: 'Global configuration', module: 'configuration' },
|
||||
{ href: '/isac/calculations', label: 'Combat calculations', module: 'calculations' },
|
||||
{ href: '/isac/specializations', label: 'Specializations', module: 'specializations' },
|
||||
{ href: '/isac/attributes', label: 'Attributes', module: 'attributes' },
|
||||
{ href: '/isac/talents', label: 'Talents', module: 'talents' },
|
||||
|
||||
@@ -73,7 +73,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
SUM(CASE WHEN entity_type='data_moderation' THEN 1 ELSE 0 END) AS data_moderation,
|
||||
SUM(CASE WHEN entity_type IN ('user','user_access','user_moderation','identity') THEN 1 ELSE 0 END) AS users,
|
||||
SUM(CASE WHEN entity_type IN ('role','role_management') THEN 1 ELSE 0 END) AS access,
|
||||
SUM(CASE WHEN entity_type='system_configuration' THEN 1 ELSE 0 END) AS system_configuration
|
||||
SUM(CASE WHEN entity_type='system_configuration' THEN 1 ELSE 0 END) AS system_configuration,
|
||||
SUM(CASE WHEN entity_type='calculation_configuration' THEN 1 ELSE 0 END) AS calculation_configuration
|
||||
FROM audit_log`)).get()
|
||||
]);
|
||||
const pagination = getPagination(url, Number(total?.total ?? 0));
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<KpiFilters className="audit-kpis" parameter="category" active={data.filters.category} items={[
|
||||
{value:'',label:'ALL EVENTS',count:data.summary.total,description:'Complete audit history'},
|
||||
{value:'game_data',label:'GAME DATA',count:data.summary.game_data,description:'Builder-source mutations'},
|
||||
{value:'calculation_configuration',label:'CALCULATION RULES',count:data.summary.calculation_configuration,description:'Combat model revisions and calibration'},
|
||||
{value:'data_moderation',label:'DATA MODERATION',count:data.summary.data_moderation,description:'Proposal lifecycle decisions'},
|
||||
{value:'user',label:'USER OPERATIONS',count:data.summary.users,description:'Access, moderation, identities'},
|
||||
{value:'access_policy',label:'ACCESS POLICY',count:data.summary.access,description:'Role definition changes'}
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
|
||||
import { migrateLoadoutDraft } from '$lib/builder/loadout';
|
||||
|
||||
const operators = new Set([
|
||||
'base_damage', 'flat_add', 'additive_percent', 'multiplicative_percent',
|
||||
'expected_critical', 'expected_headshot', 'rate_of_fire', 'magazine_size',
|
||||
'reload_speed', 'firing_cycle'
|
||||
]);
|
||||
const curves = new Set(['none', 'power', 'smoothstep']);
|
||||
|
||||
type StageInput = {
|
||||
stageKey?: string;
|
||||
label?: string;
|
||||
operator?: string;
|
||||
displayOrder?: number | string;
|
||||
lockedPosition?: string | null;
|
||||
};
|
||||
type ProfileInput = {
|
||||
statAttributeId?: string;
|
||||
stageKey?: string;
|
||||
weight?: number | string;
|
||||
softCap?: number | string | null;
|
||||
hardCap?: number | string | null;
|
||||
curveType?: string;
|
||||
curveParameter?: number | string | null;
|
||||
weaponCategory?: string;
|
||||
};
|
||||
|
||||
const clean = (value: unknown) => String(value ?? '').normalize('NFKC').trim();
|
||||
const optionalNumber = (value: unknown) => {
|
||||
if (value === '' || value === null || value === undefined) return null;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : Number.NaN;
|
||||
};
|
||||
const parseArray = <T>(value: unknown): T[] => {
|
||||
try {
|
||||
const parsed = JSON.parse(String(value ?? '[]'));
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
async function revisionSnapshot(database: Awaited<ReturnType<typeof getDatabase>>, id: string) {
|
||||
return {
|
||||
record: await (await database.prepare('SELECT * FROM calculation_revisions WHERE id=?')).get(id) ?? null,
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const where = search ? "WHERE calibration.name LIKE ? ESCAPE '\\' COLLATE NOCASE" : '';
|
||||
const args = search ? [pattern] : [];
|
||||
const total = Number((await (await database.prepare(
|
||||
`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([
|
||||
(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 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
|
||||
LEFT JOIN users user ON user.id=calibration.created_by
|
||||
LEFT JOIN user_profiles profile ON profile.user_id=user.id
|
||||
${where} ORDER BY calibration.updated_at DESC LIMIT ? OFFSET ?`
|
||||
)).all(...args, pagination.perPage, pagination.offset),
|
||||
(await database.prepare(`SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN status='ready' THEN 1 ELSE 0 END) AS ready,
|
||||
SUM(CASE WHEN status='draft' THEN 1 ELSE 0 END) AS draft,
|
||||
SUM(CASE WHEN predicted_json IS NOT NULL THEN 1 ELSE 0 END) AS predicted
|
||||
FROM calculation_calibration_cases`)).get()
|
||||
]);
|
||||
return {
|
||||
revisions: revisions.map((revision) => ({
|
||||
...revision,
|
||||
stages: stages.filter((stage) => stage.revision_id === revision.id),
|
||||
profiles: profiles.filter((profile) => profile.revision_id === revision.id)
|
||||
})),
|
||||
attributes,
|
||||
calibrationCases,
|
||||
calibrationSummary,
|
||||
pagination,
|
||||
search
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
saveRevision: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const form = await request.formData();
|
||||
const id = clean(form.get('id'));
|
||||
const name = clean(form.get('name'));
|
||||
const notes = clean(form.get('notes')).slice(0, 4000);
|
||||
const stageInputs = parseArray<StageInput>(form.get('stagesJson')).map((stage) => ({
|
||||
stageKey: clean(stage.stageKey),
|
||||
label: clean(stage.label),
|
||||
operator: clean(stage.operator),
|
||||
displayOrder: Number(stage.displayOrder),
|
||||
lockedPosition: clean(stage.lockedPosition) || null
|
||||
}));
|
||||
const profileInputs = parseArray<ProfileInput>(form.get('profilesJson')).map((profile) => ({
|
||||
statAttributeId: clean(profile.statAttributeId),
|
||||
stageKey: clean(profile.stageKey),
|
||||
weight: Number(profile.weight),
|
||||
softCap: optionalNumber(profile.softCap),
|
||||
hardCap: optionalNumber(profile.hardCap),
|
||||
curveType: clean(profile.curveType) || 'none',
|
||||
curveParameter: optionalNumber(profile.curveParameter),
|
||||
weaponCategory: clean(profile.weaponCategory) || '*'
|
||||
}));
|
||||
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
|
||||
|| new Set(stageInputs.map((stage) => stage.displayOrder)).size !== stageInputs.length
|
||||
|| stageInputs.some((stage) => !stage.stageKey || stage.label.length < 2 || !operators.has(stage.operator)
|
||||
|| !Number.isInteger(stage.displayOrder) || stage.displayOrder < 1
|
||||
|| (stage.lockedPosition !== null && !['first', 'last'].includes(stage.lockedPosition)))) {
|
||||
return fail(400, { revisionError: 'Every calculation stage requires a unique key, order, label, and supported operator.' });
|
||||
}
|
||||
const ordered = [...stageInputs].sort((left, right) => left.displayOrder - right.displayOrder);
|
||||
if (ordered[0].operator !== 'base_damage' || ordered[0].lockedPosition !== 'first'
|
||||
|| ordered.at(-1)?.operator !== 'firing_cycle' || ordered.at(-1)?.lockedPosition !== 'last') {
|
||||
return fail(400, { revisionError: 'Base damage must remain first and the firing cycle must remain last.' });
|
||||
}
|
||||
const stageKeys = new Set(stageInputs.map((stage) => stage.stageKey));
|
||||
if (profileInputs.length > 2000 || profileInputs.some((profile) =>
|
||||
!profile.statAttributeId || !stageKeys.has(profile.stageKey) || !Number.isFinite(profile.weight)
|
||||
|| profile.weight < -100 || profile.weight > 100
|
||||
|| Number.isNaN(profile.softCap) || Number.isNaN(profile.hardCap) || Number.isNaN(profile.curveParameter)
|
||||
|| (profile.softCap !== null && profile.softCap < 0)
|
||||
|| (profile.hardCap !== null && profile.hardCap < 0)
|
||||
|| (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.' });
|
||||
const database = await getDatabase();
|
||||
const attributeIds = [...new Set(profileInputs.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(',')})`
|
||||
)).get(...attributeIds))?.count ?? 0);
|
||||
if (count !== attributeIds.length) return fail(400, { revisionError: 'One or more calculation Attributes are unavailable.' });
|
||||
}
|
||||
const existing = id ? await (await database.prepare('SELECT * FROM calculation_revisions WHERE id=?')).get(id) : null;
|
||||
if (id && !existing) return fail(404, { revisionError: 'That calculation revision no longer exists.' });
|
||||
if (existing?.status !== 'draft') return fail(409, { revisionError: 'Active and retired revisions are immutable. Create a new draft.' });
|
||||
const revisionId = id || clean(form.get('_proposedEntityId')) || randomUUID();
|
||||
const before = id ? await revisionSnapshot(database, revisionId) : null;
|
||||
const now = new Date().toISOString();
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
if (id) {
|
||||
await (await database.prepare('UPDATE calculation_revisions SET name=?,notes=? WHERE id=?'))
|
||||
.run(name, notes || null, revisionId);
|
||||
} else {
|
||||
const version = Number((await (await database.prepare('SELECT COALESCE(MAX(version),0)+1 AS version FROM calculation_revisions')).get())?.version ?? 1);
|
||||
const basedOn = clean(form.get('basedOnRevisionId')) || null;
|
||||
await (await database.prepare(`INSERT INTO calculation_revisions
|
||||
(id,version,name,status,notes,based_on_revision_id,created_by,created_at)
|
||||
VALUES(?,?,?,'draft',?,?,?,?)`))
|
||||
.run(revisionId, version, name, notes || null, basedOn, operator.id, now);
|
||||
}
|
||||
await (await database.prepare('DELETE FROM calculation_stages WHERE revision_id=?')).run(revisionId);
|
||||
for (const stage of ordered) {
|
||||
await (await database.prepare(`INSERT INTO calculation_stages
|
||||
(id,revision_id,stage_key,label,operator,display_order,locked_position) VALUES(?,?,?,?,?,?,?)`))
|
||||
.run(randomUUID(), revisionId, stage.stageKey, stage.label, stage.operator, stage.displayOrder, stage.lockedPosition);
|
||||
}
|
||||
await (await database.prepare('DELETE FROM attribute_calculation_profiles WHERE revision_id=?')).run(revisionId);
|
||||
for (const profile of profileInputs) {
|
||||
await (await database.prepare(`INSERT INTO attribute_calculation_profiles
|
||||
(id,revision_id,stat_attribute_id,stage_key,weight,soft_cap,hard_cap,curve_type,curve_parameter,weapon_category)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`)).run(
|
||||
randomUUID(), revisionId, profile.statAttributeId, profile.stageKey, profile.weight,
|
||||
profile.softCap, profile.hardCap, profile.curveType, profile.curveParameter, profile.weaponCategory
|
||||
);
|
||||
}
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'calculation_configuration',
|
||||
action: id ? 'calculation_revision.updated' : 'calculation_revision.created',
|
||||
entityId: revisionId,
|
||||
before,
|
||||
after: await revisionSnapshot(database, revisionId)
|
||||
});
|
||||
return { revisionSaved: true, revisionMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||
},
|
||||
|
||||
publishRevision: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'calculation.publish');
|
||||
const form = await request.formData();
|
||||
const id = clean(form.get('id'));
|
||||
if (form.get('confirmation') !== 'activate-current-rules') {
|
||||
return fail(400, { publishError: 'Confirm that all saved builds will use this revision.' });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const candidate = await (await database.prepare("SELECT * FROM calculation_revisions WHERE id=? AND status='draft'")).get(id);
|
||||
if (!candidate) return fail(404, { publishError: 'That draft revision is no longer available.' });
|
||||
const before = {
|
||||
active: await (await database.prepare("SELECT * FROM calculation_revisions WHERE status='active'")).get() ?? null,
|
||||
candidate: await revisionSnapshot(database, id)
|
||||
};
|
||||
const now = new Date().toISOString();
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
await (await database.prepare("UPDATE calculation_revisions SET status='retired' WHERE status='active'")).run();
|
||||
await (await database.prepare(`UPDATE calculation_revisions SET status='active',published_by=?,published_at=? WHERE id=?`))
|
||||
.run(operator.id, now, id);
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'calculation_configuration',
|
||||
action: 'calculation_revision.published',
|
||||
entityId: id,
|
||||
before,
|
||||
after: { active: await revisionSnapshot(database, id) }
|
||||
});
|
||||
return { revisionPublished: true, resultToken: randomUUID() };
|
||||
},
|
||||
|
||||
saveCalibration: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const form = await request.formData();
|
||||
const id = clean(form.get('id'));
|
||||
const name = clean(form.get('name'));
|
||||
const status = clean(form.get('status')) || 'draft';
|
||||
const snapshotText = String(form.get('loadoutSnapshot') ?? '').trim();
|
||||
let snapshot: unknown;
|
||||
try { snapshot = JSON.parse(snapshotText); } catch { return fail(400, { calibrationError: 'The loadout snapshot is not valid JSON.' }); }
|
||||
const migrated = migrateLoadoutDraft(snapshot);
|
||||
if (!migrated) return fail(400, { calibrationError: 'The loadout snapshot is not a recognized SHD.build loadout.' });
|
||||
const observed = {
|
||||
bodyHit: optionalNumber(form.get('bodyHit')),
|
||||
criticalBodyHit: optionalNumber(form.get('criticalBodyHit')),
|
||||
headshot: optionalNumber(form.get('headshot')),
|
||||
criticalHeadshot: optionalNumber(form.get('criticalHeadshot')),
|
||||
magazineSeconds: optionalNumber(form.get('magazineSeconds')),
|
||||
reloadSeconds: optionalNumber(form.get('reloadSeconds')),
|
||||
encounterDps: optionalNumber(form.get('encounterDps'))
|
||||
};
|
||||
if (name.length < 3 || name.length > 120 || !['draft', 'ready'].includes(status)) {
|
||||
return fail(400, { calibrationError: 'Calibration cases require a 3–120 character name and valid state.' });
|
||||
}
|
||||
if (Object.values(observed).some((value) => Number.isNaN(value) || (value !== null && value < 0))
|
||||
|| (observed.magazineSeconds !== null && observed.magazineSeconds <= 0)
|
||||
|| (observed.reloadSeconds !== null && observed.reloadSeconds <= 0)) {
|
||||
return fail(400, { calibrationError: 'Observed values must be valid non-negative measurements.' });
|
||||
}
|
||||
const core = [observed.bodyHit, observed.criticalBodyHit, observed.headshot, observed.criticalHeadshot, observed.magazineSeconds, observed.reloadSeconds];
|
||||
if (status === 'ready' && core.some((value) => value === null)) {
|
||||
return fail(400, { calibrationError: 'Ready cases require the four hit values, magazine time, and reload time.' });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const caseId = id || clean(form.get('_proposedEntityId')) || randomUUID();
|
||||
const before = id ? await (await database.prepare('SELECT * FROM calculation_calibration_cases WHERE id=?')).get(id) ?? null : null;
|
||||
if (id && !before) return fail(404, { calibrationError: 'That calibration case no longer exists.' });
|
||||
const now = new Date().toISOString();
|
||||
const values = [
|
||||
name, status, JSON.stringify(migrated), Number(form.get('selectedWeapon') ?? 0),
|
||||
clean(form.get('gameVersion')) || null, clean(form.get('observedAt')) || null,
|
||||
clean(form.get('notes')).slice(0, 4000) || null,
|
||||
observed.bodyHit, observed.criticalBodyHit, observed.headshot, observed.criticalHeadshot,
|
||||
observed.magazineSeconds, observed.reloadSeconds, observed.encounterDps,
|
||||
clean(form.get('predictedJson')) || null
|
||||
];
|
||||
if (id) {
|
||||
await (await database.prepare(`UPDATE calculation_calibration_cases SET
|
||||
name=?,status=?,loadout_snapshot_json=?,selected_weapon=?,game_version=?,observed_at=?,notes=?,
|
||||
body_hit=?,critical_body_hit=?,headshot=?,critical_headshot=?,magazine_seconds=?,reload_seconds=?,
|
||||
encounter_dps=?,predicted_json=?,updated_at=? WHERE id=?`)).run(...values, now, caseId);
|
||||
} else {
|
||||
const activeRevision = await (await database.prepare("SELECT id FROM calculation_revisions WHERE status='active'")).get();
|
||||
await (await database.prepare(`INSERT INTO calculation_calibration_cases
|
||||
(id,name,status,loadout_snapshot_json,selected_weapon,game_version,observed_at,notes,
|
||||
body_hit,critical_body_hit,headshot,critical_headshot,magazine_seconds,reload_seconds,
|
||||
encounter_dps,predicted_json,calculation_revision_id,created_by,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`))
|
||||
.run(caseId, ...values, activeRevision?.id ?? null, operator.id, now, now);
|
||||
}
|
||||
const after = await (await database.prepare('SELECT * FROM calculation_calibration_cases WHERE id=?')).get(caseId) ?? null;
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'calculation_configuration',
|
||||
action: id ? 'calibration_case.updated' : 'calibration_case.created',
|
||||
entityId: caseId,
|
||||
before,
|
||||
after
|
||||
});
|
||||
return { calibrationSaved: true, calibrationMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||
},
|
||||
|
||||
suggestRevision: async ({ cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const database = await getDatabase();
|
||||
const cases = await (await database.prepare(
|
||||
"SELECT * FROM calculation_calibration_cases WHERE status='ready' AND predicted_json IS NOT NULL"
|
||||
)).all();
|
||||
if (!cases.length) return fail(409, { suggestionError: 'Add at least one ready calibration case with a prediction before requesting suggestions.' });
|
||||
const ratios: number[] = [];
|
||||
for (const item of cases) {
|
||||
try {
|
||||
const predicted = JSON.parse(String(item.predicted_json));
|
||||
const observed = Number(item.body_hit);
|
||||
const modeled = Number(predicted.bodyHit ?? predicted.hitValues?.normal);
|
||||
if (observed > 0 && modeled > 0) ratios.push(observed / modeled);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!ratios.length) return fail(409, { suggestionError: 'Ready cases do not contain compatible body-hit predictions.' });
|
||||
const scale = Math.min(3, Math.max(0.25, ratios.reduce((sum, value) => sum + value, 0) / ratios.length));
|
||||
const active = await (await database.prepare("SELECT * FROM calculation_revisions WHERE status='active'")).get();
|
||||
if (!active) return fail(409, { suggestionError: 'No active calculation revision is available.' });
|
||||
const activeSnapshot = await revisionSnapshot(database, String(active.id));
|
||||
const revisionId = randomUUID();
|
||||
const version = Number((await (await database.prepare('SELECT COALESCE(MAX(version),0)+1 AS version FROM calculation_revisions')).get())?.version ?? 1);
|
||||
const now = new Date().toISOString();
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
await (await database.prepare(`INSERT INTO calculation_revisions
|
||||
(id,version,name,status,notes,based_on_revision_id,created_by,created_at)
|
||||
VALUES(?,?,?,'draft',?,?,?,?)`)).run(
|
||||
revisionId, version, `Calibration suggestion ${version}`,
|
||||
`Suggested from ${ratios.length} ready case${ratios.length === 1 ? '' : 's'}. Body-hit scale ${scale.toFixed(4)}.`,
|
||||
active.id, operator.id, now
|
||||
);
|
||||
for (const stage of activeSnapshot.stages) {
|
||||
await (await database.prepare(`INSERT INTO calculation_stages
|
||||
(id,revision_id,stage_key,label,operator,display_order,locked_position) VALUES(?,?,?,?,?,?,?)`))
|
||||
.run(randomUUID(), revisionId, stage.stage_key, stage.label, stage.operator, stage.display_order, stage.locked_position);
|
||||
}
|
||||
for (const profile of activeSnapshot.profiles) {
|
||||
const shouldScale = ['flat_add', 'additive_percent', 'multiplicative_percent'].includes(
|
||||
String(activeSnapshot.stages.find((stage) => stage.stage_key === profile.stage_key)?.operator)
|
||||
);
|
||||
await (await database.prepare(`INSERT INTO attribute_calculation_profiles
|
||||
(id,revision_id,stat_attribute_id,stage_key,weight,soft_cap,hard_cap,curve_type,curve_parameter,weapon_category)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`)).run(
|
||||
randomUUID(), revisionId, profile.stat_attribute_id, profile.stage_key,
|
||||
Number(profile.weight) * (shouldScale ? scale : 1), profile.soft_cap, profile.hard_cap,
|
||||
profile.curve_type, profile.curve_parameter, profile.weapon_category
|
||||
);
|
||||
}
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'calculation_configuration',
|
||||
action: 'calculation_revision.suggested',
|
||||
entityId: revisionId,
|
||||
before: { activeRevisionId: active.id, readyCases: cases.map((item) => item.id) },
|
||||
after: { ...(await revisionSnapshot(database, revisionId)), suggestedScale: scale }
|
||||
});
|
||||
return { suggestionSaved: true, suggestedRevisionId: revisionId, resultToken: randomUUID() };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,245 @@
|
||||
<script lang="ts">
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import { createDraft } from '$lib/builder/loadout';
|
||||
|
||||
let { data, form } = $props();
|
||||
type StageEditor = {
|
||||
stageKey: string;
|
||||
label: string;
|
||||
operator: string;
|
||||
displayOrder: number;
|
||||
lockedPosition: string | null;
|
||||
};
|
||||
type ProfileEditor = {
|
||||
statAttributeId: string;
|
||||
stageKey: string;
|
||||
weight: number | string;
|
||||
softCap: number | string;
|
||||
hardCap: number | string;
|
||||
curveType: string;
|
||||
curveParameter: number | string;
|
||||
weaponCategory: string;
|
||||
};
|
||||
type RevisionEditor = {
|
||||
id: string;
|
||||
basedOnRevisionId: string;
|
||||
name: string;
|
||||
notes: string;
|
||||
stages: StageEditor[];
|
||||
profiles: ProfileEditor[];
|
||||
};
|
||||
type CalibrationEditor = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
loadoutSnapshot: string;
|
||||
selectedWeapon: string;
|
||||
gameVersion: string;
|
||||
observedAt: string;
|
||||
notes: string;
|
||||
bodyHit: number | string;
|
||||
criticalBodyHit: number | string;
|
||||
headshot: number | string;
|
||||
criticalHeadshot: number | string;
|
||||
magazineSeconds: number | string;
|
||||
reloadSeconds: number | string;
|
||||
encounterDps: number | string;
|
||||
predictedJson: string;
|
||||
};
|
||||
const canPublish = $derived(data.permissions?.includes('*') || data.permissions?.includes('calculation.publish'));
|
||||
const activeRevision = $derived(data.revisions.find((revision: any) => revision.status === 'active'));
|
||||
const stageItems = (revision: RevisionEditor) => revision.stages.map((stage) => ({
|
||||
id: stage.stageKey,
|
||||
name: stage.label,
|
||||
category: stage.operator.replaceAll('_', ' ').toUpperCase()
|
||||
}));
|
||||
const curveOptions = [
|
||||
{ value: 'none', label: 'NONE' },
|
||||
{ value: 'power', label: 'POWER' },
|
||||
{ value: 'smoothstep', label: 'SMOOTHSTEP' }
|
||||
];
|
||||
const stateOptions = [{ value: 'draft', label: 'DRAFT' }, { value: 'ready', label: 'READY' }];
|
||||
const weaponOptions = [{ value: '0', label: 'PRIMARY' }, { value: '1', label: 'SECONDARY' }];
|
||||
|
||||
const revisionFrom = (revision: any, edit = false): RevisionEditor => ({
|
||||
id: edit ? String(revision?.id ?? '') : '',
|
||||
basedOnRevisionId: edit ? String(revision?.based_on_revision_id ?? '') : String(revision?.id ?? ''),
|
||||
name: edit ? String(revision?.name ?? '') : `Combat model ${Number(revision?.version ?? 0) + 1}`,
|
||||
notes: edit ? String(revision?.notes ?? '') : `Based on revision ${revision?.version ?? 1}.`,
|
||||
stages: (revision?.stages ?? []).map((stage: any) => ({
|
||||
stageKey: String(stage.stage_key),
|
||||
label: String(stage.label),
|
||||
operator: String(stage.operator),
|
||||
displayOrder: Number(stage.display_order),
|
||||
lockedPosition: stage.locked_position ? String(stage.locked_position) : null
|
||||
})),
|
||||
profiles: (revision?.profiles ?? []).map((profile: any) => ({
|
||||
statAttributeId: String(profile.stat_attribute_id),
|
||||
stageKey: String(profile.stage_key),
|
||||
weight: Number(profile.weight),
|
||||
softCap: profile.soft_cap ?? '',
|
||||
hardCap: profile.hard_cap ?? '',
|
||||
curveType: String(profile.curve_type),
|
||||
curveParameter: profile.curve_parameter ?? '',
|
||||
weaponCategory: String(profile.weapon_category ?? '*')
|
||||
}))
|
||||
});
|
||||
const blankCalibration = (): CalibrationEditor => ({
|
||||
id: '', name: '', status: 'draft', loadoutSnapshot: JSON.stringify(createDraft(), null, 2),
|
||||
selectedWeapon: '0', gameVersion: '', observedAt: '', notes: '', bodyHit: '',
|
||||
criticalBodyHit: '', headshot: '', criticalHeadshot: '', magazineSeconds: '',
|
||||
reloadSeconds: '', encounterDps: '', predictedJson: ''
|
||||
});
|
||||
const calibrationFrom = (item: any): CalibrationEditor => ({
|
||||
id: String(item.id), name: String(item.name), status: String(item.status),
|
||||
loadoutSnapshot: JSON.stringify(JSON.parse(String(item.loadout_snapshot_json)), null, 2),
|
||||
selectedWeapon: String(item.selected_weapon), gameVersion: String(item.game_version ?? ''),
|
||||
observedAt: String(item.observed_at ?? ''), notes: String(item.notes ?? ''),
|
||||
bodyHit: item.body_hit ?? '', criticalBodyHit: item.critical_body_hit ?? '',
|
||||
headshot: item.headshot ?? '', criticalHeadshot: item.critical_headshot ?? '',
|
||||
magazineSeconds: item.magazine_seconds ?? '', reloadSeconds: item.reload_seconds ?? '',
|
||||
encounterDps: item.encounter_dps ?? '', predictedJson: String(item.predicted_json ?? '')
|
||||
});
|
||||
|
||||
let revisionEditing = $state<RevisionEditor | null>(null);
|
||||
let calibrationEditing = $state<CalibrationEditor | null>(null);
|
||||
let publishingId = $state('');
|
||||
let publishConfirmed = $state(false);
|
||||
let handledToken = $state('');
|
||||
|
||||
function addProfile() {
|
||||
if (!revisionEditing) return;
|
||||
revisionEditing.profiles = [...revisionEditing.profiles, {
|
||||
statAttributeId: '', stageKey: revisionEditing.stages.find((stage) => stage.operator === 'additive_percent')?.stageKey ?? '',
|
||||
weight: 1, softCap: '', hardCap: '', curveType: 'none', curveParameter: '', weaponCategory: '*'
|
||||
}];
|
||||
}
|
||||
function moveStage(index: number, direction: -1 | 1) {
|
||||
if (!revisionEditing) return;
|
||||
const target = index + direction;
|
||||
const stages = [...revisionEditing.stages].sort((left, right) => left.displayOrder - right.displayOrder);
|
||||
if (index < 0 || target < 0 || target >= stages.length || stages[index].lockedPosition || stages[target].lockedPosition) return;
|
||||
[stages[index], stages[target]] = [stages[target], stages[index]];
|
||||
revisionEditing.stages = stages.map((stage, stageIndex) => ({ ...stage, displayOrder: stageIndex + 1 }));
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const token = String(form?.resultToken ?? '');
|
||||
if (!token || token === handledToken) return;
|
||||
handledToken = token;
|
||||
if (form?.revisionSaved) revisionEditing = form.revisionMode === 'add' ? revisionFrom(activeRevision) : null;
|
||||
if (form?.calibrationSaved) calibrationEditing = form.calibrationMode === 'add' ? blankCalibration() : null;
|
||||
if (form?.revisionPublished) {
|
||||
publishingId = '';
|
||||
publishConfirmed = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Combat calculations — ISAC</title></svelte:head>
|
||||
|
||||
<div class="module-toolbar">
|
||||
<div><p class="eyebrow">MODELED DAMAGE + EMPIRICAL CALIBRATION</p><h2>COMBAT CALCULATIONS</h2><p>Configure typed calculation stages, Attribute bindings, and field observations used by the loadout builder.</p></div>
|
||||
<div class="calculation-actions"><button class="secondary" onclick={() => calibrationEditing = blankCalibration()}>ADD CALIBRATION CASE +</button><button class="primary" onclick={() => revisionEditing = revisionFrom(activeRevision)}>NEW REVISION +</button></div>
|
||||
</div>
|
||||
|
||||
<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>READY CASES</span><b>{Number(data.calibrationSummary?.ready ?? 0)}</b><small>{Number(data.calibrationSummary?.predicted ?? 0)} WITH PREDICTIONS</small></div>
|
||||
</div>
|
||||
|
||||
{#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>
|
||||
<PendingForm action="?/saveRevision">
|
||||
<input type="hidden" name="id" value={revisionEditing.id}/>
|
||||
<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)}/>
|
||||
<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}
|
||||
<div class:locked={stage.lockedPosition}><b>{String(stage.displayOrder).padStart(2, '0')}</b><label class="field">LABEL<input bind:value={stage.label} required/></label><span>{stage.operator.replaceAll('_', ' ').toUpperCase()}</span><em>{stage.lockedPosition?.toUpperCase() ?? 'ORDERED'}</em><div class="stage-order-actions"><button type="button" class="secondary" aria-label={`Move ${stage.label} earlier`} disabled={Boolean(stage.lockedPosition) || index === 1} onclick={() => moveStage(index,-1)}>↑</button><button type="button" class="secondary" aria-label={`Move ${stage.label} later`} disabled={Boolean(stage.lockedPosition) || index === revisionEditing!.stages.length - 2} onclick={() => moveStage(index,1)}>↓</button></div>
|
||||
</div>{/each}</div>
|
||||
<div class="selection-head"><div><span>ATTRIBUTE CALCULATION PROFILES</span><small>WEIGHT, CAPS, AND SAFE CURVES</small></div><button type="button" class="secondary" onclick={addProfile}>ADD PROFILE +</button></div>
|
||||
<div class="calculation-profile-list">{#each revisionEditing.profiles as profile, index}
|
||||
<section>
|
||||
<header><span>PROFILE {String(index + 1).padStart(2, '0')}</span><button type="button" class="moderation-action danger" onclick={() => revisionEditing && (revisionEditing.profiles = revisionEditing.profiles.filter((_, itemIndex) => itemIndex !== index))}>REMOVE</button></header>
|
||||
<div class="field"><span>ATTRIBUTE</span><SearchableAssignment items={data.attributes} mode="single" compact fieldName={`profile-attribute-${index}`} subjectLabel="ATTRIBUTE" searchPlaceholder="Search Attributes…" bind:value={profile.statAttributeId}/></div>
|
||||
<div class="field"><span>CALCULATION STAGE</span><SearchableAssignment items={stageItems(revisionEditing)} mode="single" compact fieldName={`profile-stage-${index}`} subjectLabel="STAGE" searchPlaceholder="Search stages…" bind:value={profile.stageKey}/></div>
|
||||
<div class="calculation-profile-values">
|
||||
<label class="field">WEIGHT<input type="number" min="-100" max="100" step="any" bind:value={profile.weight} required/></label>
|
||||
<label class="field">SOFT CAP<input type="number" min="0" step="any" bind:value={profile.softCap} placeholder="NONE"/></label>
|
||||
<label class="field">HARD CAP<input type="number" min="0" step="any" bind:value={profile.hardCap} placeholder="NONE"/></label>
|
||||
<label class="field">CURVE PARAMETER<input type="number" min="0.01" step="any" bind:value={profile.curveParameter} disabled={profile.curveType === 'none'} placeholder="1"/></label>
|
||||
</div>
|
||||
<SegmentedChoice legend="CURVE" name={`profile-curve-${index}`} options={curveOptions} bind:value={profile.curveType} showIndicator={false}/>
|
||||
</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>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if calibrationEditing}
|
||||
<div class="dashboard-editor calibration-editor">
|
||||
<div class="editor-title"><div><p class="eyebrow">{calibrationEditing.id ? 'MODIFY CALIBRATION CASE' : 'NEW CALIBRATION CASE'}</p><h3>{calibrationEditing.name || 'FIELD OBSERVATION'}</h3></div><button aria-label="Close calibration editor" onclick={() => calibrationEditing = null}>×</button></div>
|
||||
<PendingForm action="?/saveCalibration">
|
||||
<input type="hidden" name="id" value={calibrationEditing.id}/>
|
||||
<div class="calibration-identity"><label class="field isac-record-name">CASE NAME<input name="name" bind:value={calibrationEditing.name} maxlength="120" required/></label><SegmentedChoice legend="CASE STATE" name="status" options={stateOptions} bind:value={calibrationEditing.status} showIndicator={false}/><SegmentedChoice legend="MEASURED WEAPON" name="selectedWeapon" options={weaponOptions} bind:value={calibrationEditing.selectedWeapon} showIndicator={false}/><label class="field">GAME VERSION<input name="gameVersion" bind:value={calibrationEditing.gameVersion}/></label><label class="field">OBSERVED DATE<input name="observedAt" type="date" bind:value={calibrationEditing.observedAt}/></label></div>
|
||||
<label class="field calibration-snapshot">LOADOUT SNAPSHOT · JSON<textarea name="loadoutSnapshot" bind:value={calibrationEditing.loadoutSnapshot} required spellcheck="false"></textarea></label>
|
||||
<div class="calibration-observations">
|
||||
<label class="field">BODY HIT<input name="bodyHit" type="number" min="0" step="any" bind:value={calibrationEditing.bodyHit}/></label>
|
||||
<label class="field">CRITICAL BODY<input name="criticalBodyHit" type="number" min="0" step="any" bind:value={calibrationEditing.criticalBodyHit}/></label>
|
||||
<label class="field">HEADSHOT<input name="headshot" type="number" min="0" step="any" bind:value={calibrationEditing.headshot}/></label>
|
||||
<label class="field">CRITICAL HEADSHOT<input name="criticalHeadshot" type="number" min="0" step="any" bind:value={calibrationEditing.criticalHeadshot}/></label>
|
||||
<label class="field">MAGAZINE TIME · SECONDS<input name="magazineSeconds" type="number" min="0.01" step="any" bind:value={calibrationEditing.magazineSeconds}/></label>
|
||||
<label class="field">RELOAD TIME · SECONDS<input name="reloadSeconds" type="number" min="0.01" step="any" bind:value={calibrationEditing.reloadSeconds}/></label>
|
||||
<label class="field">ENCOUNTER DPS · OPTIONAL<input name="encounterDps" type="number" min="0" step="any" bind:value={calibrationEditing.encounterDps}/></label>
|
||||
</div>
|
||||
<label class="field">NOTES<textarea name="notes" bind:value={calibrationEditing.notes} maxlength="4000"></textarea></label>
|
||||
<label class="field calibration-prediction">PREDICTION SNAPSHOT · OPTIONAL JSON<textarea name="predictedJson" bind:value={calibrationEditing.predictedJson} spellcheck="false"></textarea></label>
|
||||
{#if form?.calibrationError}<p class="error">{form.calibrationError}</p>{/if}
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => calibrationEditing = null}>CANCEL</button><button class="primary">SAVE CALIBRATION CASE →</button></div>
|
||||
</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="calculation-revision-registry">
|
||||
<div class="selection-head"><div><span>CALCULATION REVISIONS</span><small>SAVED BUILDS USE THE ACTIVE REVISION</small></div><PendingForm action="?/suggestRevision">{#snippet children(pending)}<button class="secondary" disabled={pending}>{pending ? 'ANALYZING…' : 'GENERATE ASSISTED SUGGESTION'}</button>{/snippet}</PendingForm></div>
|
||||
{#if form?.suggestionError}<p class="error">{form.suggestionError}</p>{/if}
|
||||
{#each data.revisions as revision}
|
||||
<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>
|
||||
{#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>
|
||||
<label><input type="checkbox" bind:checked={publishConfirmed}/> I understand this changes current builder results.</label>
|
||||
<PendingForm action="?/publishRevision"><input type="hidden" name="id" value={revision.id}/><input type="hidden" name="confirmation" value="activate-current-rules"/><div><button type="button" class="secondary" onclick={() => publishingId = ''}>CANCEL</button><button class="primary" disabled={!publishConfirmed}>ACTIVATE REVISION →</button></div></PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="selection-head"><div><span>CALIBRATION REGISTRY</span><small>OBSERVED GAME VALUES + MODEL COMPARISON</small></div><b>{Number(data.calibrationSummary?.total ?? 0)} CASES</b></div>
|
||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||
<div class="dashboard-table">
|
||||
<div class="dashboard-row calibration-row table-heading"><span>CASE</span><span>WEAPON</span><span>OBSERVATIONS</span><span>PREDICTION</span><span>STATE</span><span></span></div>
|
||||
{#each data.calibrationCases as item}
|
||||
<div class="dashboard-row calibration-row"><div><strong>{item.name}</strong><small>{item.game_version || 'GAME VERSION NOT RECORDED'} · {item.creator_name}</small></div><span>{Number(item.selected_weapon) === 0 ? 'PRIMARY' : 'SECONDARY'}</span><span>{[item.body_hit,item.critical_body_hit,item.headshot,item.critical_headshot,item.magazine_seconds,item.reload_seconds].filter((value) => value !== null).length} / 6 CORE</span><span>{item.predicted_json ? 'AVAILABLE' : 'INSUFFICIENT DATA'}</span><b class:configured={item.status === 'ready'} class="state-dot">{item.status.toUpperCase()}</b><button onclick={() => calibrationEditing = calibrationFrom(item)}>EDIT →</button></div>
|
||||
{/each}
|
||||
</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}}
|
||||
</style>
|
||||
@@ -10,7 +10,7 @@ import type { Actions, PageServerLoad } from './$types';
|
||||
import { createNotification } from '$lib/server/notifications';
|
||||
|
||||
const statuses = ['pending', 'approved', 'rejected', 'failed'];
|
||||
const modules = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips', 'specializations'];
|
||||
const modules = ['configuration', 'calculations', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips', 'specializations'];
|
||||
type SerializedEntry =
|
||||
| { name: string; kind: 'text'; value: string }
|
||||
| { name: string; kind: 'file'; value: string; fileName: string; type: string };
|
||||
@@ -38,6 +38,14 @@ async function specializationProposalSnapshot(database: any, proposal: any) {
|
||||
)).all(id)
|
||||
};
|
||||
}
|
||||
if (proposal.action === 'saveCombatEffect') {
|
||||
return {
|
||||
record: await (await database.prepare('SELECT * FROM specialization_combat_effects WHERE id=?')).get(id) ?? null,
|
||||
attachments: await (await database.prepare(
|
||||
'SELECT id,node_id,rank,effect_order FROM specialization_tree_rank_combat_effects WHERE combat_effect_id=? ORDER BY node_id,rank,effect_order'
|
||||
)).all(id)
|
||||
};
|
||||
}
|
||||
if (proposal.action === 'saveFocusAbilities') {
|
||||
return {
|
||||
record: await (await database.prepare('SELECT * FROM specialization_focuses WHERE id=?')).get(id) ?? null,
|
||||
@@ -57,6 +65,9 @@ async function specializationProposalSnapshot(database: any, proposal: any) {
|
||||
)).all(id),
|
||||
costs: await (await database.prepare(
|
||||
'SELECT * FROM specialization_tree_rank_costs WHERE node_id=? ORDER BY rank,resource_id'
|
||||
)).all(id),
|
||||
combatEffects: await (await database.prepare(
|
||||
'SELECT * FROM specialization_tree_rank_combat_effects WHERE node_id=? ORDER BY rank,effect_order'
|
||||
)).all(id)
|
||||
};
|
||||
}
|
||||
@@ -88,6 +99,22 @@ async function skillChipProposalSnapshot(database: any, id: string) {
|
||||
};
|
||||
}
|
||||
|
||||
async function calculationProposalSnapshot(database: any, proposal: any) {
|
||||
const id = String(proposal.entity_id ?? '');
|
||||
if (!id) return null;
|
||||
if (proposal.action === 'saveRevision') return {
|
||||
record: await (await database.prepare('SELECT * FROM calculation_revisions WHERE id=?')).get(id) ?? null,
|
||||
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)
|
||||
};
|
||||
if (proposal.action === 'saveCalibration') return {
|
||||
record: await (await database.prepare('SELECT * FROM calculation_calibration_cases WHERE id=?')).get(id) ?? null
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await requirePermission(await getSessionUser(cookies), 'data.approve');
|
||||
const database = await getDatabase();
|
||||
@@ -115,6 +142,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
? await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(String(proposal.entity_id)) ?? null
|
||||
: proposal.module === 'skillchips' && proposal.entity_id
|
||||
? await skillChipProposalSnapshot(database, String(proposal.entity_id))
|
||||
: proposal.module === 'calculations'
|
||||
? await calculationProposalSnapshot(database, proposal)
|
||||
: proposal.module === 'specializations'
|
||||
? await specializationProposalSnapshot(database, proposal)
|
||||
: null
|
||||
|
||||
@@ -20,7 +20,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const conditions = [
|
||||
...(permissionPrefix ? [`EXISTS (
|
||||
SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id
|
||||
WHERE rp.role_id=roles.id AND p.name LIKE ?
|
||||
WHERE rp.role_id=roles.id AND (p.name LIKE ?${filter === 'data' ? " OR p.name LIKE 'calculation.%'" : ''})
|
||||
)`] : []),
|
||||
...(search ? ["roles.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
|
||||
];
|
||||
@@ -29,7 +29,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const [summary, filtered, permissions] = await Promise.all([
|
||||
(await database.prepare(`SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'isac.%') THEN 1 ELSE 0 END) AS isac,
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'data.%') THEN 1 ELSE 0 END) AS data,
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND (p.name LIKE 'data.%' OR p.name LIKE 'calculation.%')) THEN 1 ELSE 0 END) AS data,
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'users.%') THEN 1 ELSE 0 END) AS users,
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'audit.%') THEN 1 ELSE 0 END) AS audit
|
||||
,SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'system.%') THEN 1 ELSE 0 END) AS system
|
||||
|
||||
@@ -42,10 +42,27 @@ 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[];
|
||||
};
|
||||
|
||||
type RankInput = {
|
||||
rank?: number;
|
||||
description?: string;
|
||||
effects?: RankEffectInput[];
|
||||
combatEffects?: RankCombatEffectInput[];
|
||||
costs?: Record<string, number | string>;
|
||||
};
|
||||
|
||||
@@ -103,6 +120,9 @@ async function abilitySnapshot(database: Database, id: string) {
|
||||
}
|
||||
|
||||
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);
|
||||
return {
|
||||
record: await auditRecord('specialization_tree_nodes', id),
|
||||
ranks: await (await database.prepare(
|
||||
@@ -114,10 +134,25 @@ async function nodeSnapshot(database: Database, id: string) {
|
||||
costs: await (await database.prepare(
|
||||
'SELECT * FROM specialization_tree_rank_costs WHERE node_id=? ORDER BY rank,resource_id'
|
||||
)).all(id),
|
||||
combatEffects,
|
||||
combatModifiers: combatEffects.length ? await (await database.prepare(
|
||||
`SELECT modifier.* FROM specialization_tree_rank_combat_modifiers modifier
|
||||
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) : [],
|
||||
icon: await gameDataIconMetadata(database, 'specialization_node', id)
|
||||
};
|
||||
}
|
||||
|
||||
async function combatEffectSnapshot(database: Database, id: string) {
|
||||
return {
|
||||
record: await auditRecord('specialization_combat_effects', id),
|
||||
attachments: await (await database.prepare(
|
||||
'SELECT id,node_id,rank,effect_order FROM specialization_tree_rank_combat_effects WHERE combat_effect_id=? ORDER BY node_id,rank,effect_order'
|
||||
)).all(id)
|
||||
};
|
||||
}
|
||||
|
||||
async function validateAttributeIds(database: Database, ids: string[]) {
|
||||
const unique = [...new Set(ids.filter(Boolean))];
|
||||
if (!unique.length) return true;
|
||||
@@ -158,6 +193,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
{ 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: 'saveCombatEffect', kind: 'combatEffect', fields: ['specializationId', 'descriptionTemplate'] },
|
||||
{ module: 'specializations', action: 'saveNode', kind: 'node', fields: ['focusId', 'descriptionTemplate', 'rowNumber', 'columnNumber', 'maxRank', 'ranksJson'] }
|
||||
]);
|
||||
const pendingSpecializations = pending.filter((item) => item.kind === 'specialization')
|
||||
@@ -180,7 +216,7 @@ 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, resources, rankProfiles, progression, attributes, chipCounts, icons, summary] = await Promise.all([
|
||||
const [specializations, focuses, abilities, 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 active DESC,name COLLATE NOCASE LIMIT ? OFFSET ?`
|
||||
)).all(...args, databaseLimit, databaseOffset) : [],
|
||||
@@ -192,6 +228,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
(await database.prepare('SELECT * FROM specialization_tree_node_ranks ORDER BY node_id,rank')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_tree_rank_costs ORDER BY node_id,rank,resource_id')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_combat_effects ORDER BY specialization_id,active DESC,name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_tree_rank_combat_effects ORDER BY node_id,rank,effect_order')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_tree_rank_combat_modifiers ORDER BY rank_combat_effect_id,modifier_order')).all(),
|
||||
(await database.prepare('SELECT * FROM upgrade_resources WHERE active=1 ORDER BY resource_type,name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_rank_cost_profiles ORDER BY rank')).all(),
|
||||
(await database.prepare("SELECT * FROM specialization_progression_config WHERE id='default'")).get(),
|
||||
@@ -217,6 +256,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
]);
|
||||
const pendingFocuses = pending.filter((item) => item.kind === 'focus');
|
||||
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 iconUrl = (type: 'specialization' | 'specialization_ability' | 'specialization_node', id: string) => {
|
||||
const icon = icons.find((item) => item.entity_type === type && item.entity_id === id);
|
||||
@@ -275,6 +315,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
ranks: nodeRanks.filter((rank) => rank.node_id === node.id).map((rank) => ({
|
||||
...rank,
|
||||
effects: nodeEffects.filter((effect) => effect.node_id === node.id && Number(effect.rank) === Number(rank.rank)),
|
||||
combatEffects: nodeCombatEffects
|
||||
.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)
|
||||
})),
|
||||
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)]))
|
||||
})),
|
||||
@@ -302,6 +348,16 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
};
|
||||
}),
|
||||
abilities: loadedAbilities,
|
||||
combatEffects: [
|
||||
...pendingCombatEffects.map((item) => ({
|
||||
...item,
|
||||
specialization_id: item.specializationId,
|
||||
description_template: item.descriptionTemplate,
|
||||
active: 1,
|
||||
pending: true
|
||||
})),
|
||||
...combatEffects
|
||||
],
|
||||
nodes: loadedNodes,
|
||||
resources,
|
||||
rankProfiles,
|
||||
@@ -310,7 +366,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
summary: {
|
||||
...summary,
|
||||
total: Number(summary?.total ?? 0) + pendingSpecializations.length,
|
||||
pending: pending.filter((item) => ['specialization', 'focus', 'ability', 'node'].includes(item.kind)).length
|
||||
pending: pending.filter((item) => ['specialization', 'focus', 'ability', 'combatEffect', 'node'].includes(item.kind)).length
|
||||
},
|
||||
pagination,
|
||||
filter,
|
||||
@@ -584,6 +640,61 @@ export const actions: Actions = {
|
||||
return { abilitySaved: true, abilityMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||
},
|
||||
|
||||
saveCombatEffect: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const specializationId = String(form.get('specializationId') ?? '');
|
||||
const name = cleanName(form.get('name'));
|
||||
const descriptionTemplate = cleanName(form.get('descriptionTemplate'));
|
||||
if (name.length < 2 || name.length > 100) {
|
||||
return fail(400, { combatEffectError: 'Combat-effect names must be 2–100 characters.' });
|
||||
}
|
||||
if (descriptionTemplate.length < 3 || descriptionTemplate.length > 2400) {
|
||||
return fail(400, { combatEffectError: 'The effect description template must be 3–2,400 characters.' });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const specialization = await (await database.prepare(
|
||||
'SELECT id FROM specializations WHERE id=? AND active=1'
|
||||
)).get(specializationId);
|
||||
if (!specialization) return fail(404, { combatEffectError: 'That active specialization no longer exists.' });
|
||||
if (id) {
|
||||
const existing = await (await database.prepare(
|
||||
'SELECT specialization_id FROM specialization_combat_effects WHERE id=?'
|
||||
)).get(id);
|
||||
if (!existing) return fail(404, { combatEffectError: 'That combat effect no longer exists.' });
|
||||
if (String(existing.specialization_id) !== specializationId) {
|
||||
return fail(400, { combatEffectError: 'A saved combat effect cannot be moved to another specialization.' });
|
||||
}
|
||||
}
|
||||
if (await (await database.prepare(
|
||||
'SELECT id FROM specialization_combat_effects WHERE specialization_id=? AND name=? COLLATE NOCASE AND id<>?'
|
||||
)).get(specializationId, name, id || 'new')) {
|
||||
return fail(409, { combatEffectError: 'That combat-effect name already exists in this specialization.' });
|
||||
}
|
||||
const effectId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||||
const before = id ? await combatEffectSnapshot(database, id) : null;
|
||||
const now = new Date().toISOString();
|
||||
if (id) {
|
||||
await (await database.prepare(`UPDATE specialization_combat_effects
|
||||
SET name=?,description_template=?,active=1,updated_at=? WHERE id=?`))
|
||||
.run(name, descriptionTemplate, now, id);
|
||||
} else {
|
||||
await (await database.prepare(`INSERT INTO specialization_combat_effects
|
||||
(id,specialization_id,name,description_template,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?)`)).run(effectId, specializationId, name, descriptionTemplate, now, now);
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: id ? 'specialization_combat_effect.updated' : 'specialization_combat_effect.created',
|
||||
entityId: effectId,
|
||||
before,
|
||||
after: await combatEffectSnapshot(database, effectId)
|
||||
});
|
||||
return { combatEffectSaved: true, combatEffectMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||
},
|
||||
|
||||
saveFocusAbilities: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const form = await request.formData();
|
||||
@@ -647,6 +758,19 @@ export const actions: Actions = {
|
||||
value: effect.value === '' || effect.value === null || effect.value === undefined ? null : Number(effect.value),
|
||||
unit: cleanName(effect.unit)
|
||||
})) : [],
|
||||
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)
|
||||
})) : []
|
||||
})) : [],
|
||||
costs: Object.fromEntries(Object.entries(rank.costs ?? {}).map(([resourceId, quantity]) => [resourceId, Number(quantity)]))
|
||||
}));
|
||||
if (name.length < 2 || name.length > 100) return fail(400, { nodeError: 'Node names must be 2–100 characters.' });
|
||||
@@ -665,6 +789,7 @@ export const actions: Actions = {
|
||||
return fail(400, { nodeError: 'Configure one ordered description for every available node rank.' });
|
||||
}
|
||||
const allEffects = ranks.flatMap((rank) => rank.effects);
|
||||
const allCombatEffects = ranks.flatMap((rank) => rank.combatEffects);
|
||||
if (allEffects.length > 36 || allEffects.some((effect) => !['Attribute', 'Ability', 'Custom'].includes(effect.type)
|
||||
|| effect.label.length < 2 || effect.label.length > 100
|
||||
|| (effect.value !== null && !Number.isFinite(effect.value)) || !EFFECT_UNITS.has(effect.unit)
|
||||
@@ -672,6 +797,18 @@ export const actions: Actions = {
|
||||
|| (effect.type === 'Ability' && !effect.abilityId))) {
|
||||
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))
|
||||
)) {
|
||||
return fail(400, { nodeError: 'Every conditional effect requires a valid trigger, stack lifecycle, and at least one Attribute modifier.' });
|
||||
}
|
||||
const rankOneCosts = ranks[0]?.costs ?? {};
|
||||
if (Object.values(rankOneCosts).some((quantity) => !Number.isInteger(quantity) || quantity !== 0)) {
|
||||
return fail(400, { nodeError: 'Rank 1 cannot require Electronics Components.' });
|
||||
@@ -713,9 +850,22 @@ 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)))) {
|
||||
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))) {
|
||||
return fail(400, { nodeError: 'One or more effect abilities are unavailable for this specialization.' });
|
||||
}
|
||||
const combatEffectIds = [...new Set(allCombatEffects.map((effect) => effect.effectId))];
|
||||
if (combatEffectIds.length) {
|
||||
const count = Number((await (await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM specialization_combat_effects
|
||||
WHERE active=1 AND specialization_id=? AND id IN (${combatEffectIds.map(() => '?').join(',')})`
|
||||
)).get(String(focus.specialization_id), ...combatEffectIds))?.count ?? 0);
|
||||
if (count !== combatEffectIds.length) {
|
||||
return fail(400, { nodeError: 'One or more conditional effects are unavailable for this specialization.' });
|
||||
}
|
||||
}
|
||||
if (await (await database.prepare(
|
||||
'SELECT id FROM specialization_tree_nodes WHERE focus_id=? AND name=? COLLATE NOCASE AND id<>?'
|
||||
)).get(focusId, name, id || 'new')) return fail(409, { nodeError: 'That node name already exists in this focus.' });
|
||||
@@ -755,6 +905,21 @@ export const actions: Actions = {
|
||||
.run(randomUUID(), nodeId, rank.rank, index + 1, effect.type, effect.statId || null,
|
||||
effect.abilityId || null, effect.label, effect.value, effect.unit || null);
|
||||
}
|
||||
for (const [index, effect] of rank.combatEffects.entries()) {
|
||||
const attachmentId = randomUUID();
|
||||
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()) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
for (const [resourceId, quantity] of Object.entries(rank.costs)) {
|
||||
if (Number(quantity) > 0) {
|
||||
await (await database.prepare(`INSERT INTO specialization_tree_rank_costs
|
||||
|
||||
@@ -32,10 +32,32 @@
|
||||
active: number;
|
||||
iconUrl?: string;
|
||||
};
|
||||
type CombatEffectEditor = {
|
||||
id: string;
|
||||
specializationId: string;
|
||||
name: string;
|
||||
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[];
|
||||
};
|
||||
type RankEditor = {
|
||||
rank: number;
|
||||
description: string;
|
||||
effects: StructuredEffect[];
|
||||
combatEffects: RankCombatEffect[];
|
||||
costs: Record<string, number | string>;
|
||||
};
|
||||
type NodeEditor = {
|
||||
@@ -57,6 +79,13 @@
|
||||
{ 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 blankSpecialization = (): SpecializationEditor => ({ id: '', name: '', description: '', active: 1, iconUrl: '' });
|
||||
const blankFocus = (specializationId = ''): FocusEditor => ({
|
||||
@@ -65,10 +94,19 @@
|
||||
const blankAbility = (specializationId = ''): AbilityEditor => ({
|
||||
id: '', specializationId, name: '', description: '', durationSeconds: '', cooldownSeconds: '', effects: [], active: 1, iconUrl: ''
|
||||
});
|
||||
const blankCombatEffect = (specializationId = ''): CombatEffectEditor => ({
|
||||
id: '', specializationId, name: '', descriptionTemplate: '', active: 1
|
||||
});
|
||||
const blankCombatModifier = (): RankCombatModifier => ({ statId: '', valuePerStack: '', unit: '%' });
|
||||
const blankRankCombatEffect = (): RankCombatEffect => ({
|
||||
effectId: '', triggerCount: 1, triggerWindowSeconds: 1, durationSeconds: 10,
|
||||
maxStacks: 1, stacksPerTrigger: 1, modifiers: [blankCombatModifier()]
|
||||
});
|
||||
const blankRank = (rank: number): RankEditor => ({
|
||||
rank,
|
||||
description: '',
|
||||
effects: [],
|
||||
combatEffects: [],
|
||||
costs: rank === 2
|
||||
? { 'resource-electronics-alpha': 0, 'resource-electronics-beta': 0, 'resource-electronics-gamma': 0 }
|
||||
: rank === 3 ? { 'resource-electronics-omega': 1 } : {}
|
||||
@@ -89,6 +127,7 @@
|
||||
let specializationEditing = $state<SpecializationEditor | null>(null);
|
||||
let focusEditing = $state<FocusEditor | null>(null);
|
||||
let abilityEditing = $state<AbilityEditor | null>(null);
|
||||
let combatEffectEditing = $state<CombatEffectEditor | null>(null);
|
||||
let nodeEditing = $state<NodeEditor | null>(null);
|
||||
let progressionEditing = $state(false);
|
||||
let configuringAbilities = $state(false);
|
||||
@@ -101,11 +140,15 @@
|
||||
const specializationById = (id: string) => data.specializations.find((item: any) => item.id === id);
|
||||
const focusById = (id: string) => data.focuses.find((item: any) => item.id === id);
|
||||
const abilityById = (id: string) => data.abilities.find((item: any) => item.id === id);
|
||||
const combatEffectById = (id: string) => data.combatEffects.find((item: any) => item.id === id);
|
||||
const abilityChipCount = (ability: any) => Number(ability?.skillChipCount ?? 0);
|
||||
const focusList = (specializationId: string) => data.focuses.filter((focus: any) => focus.specialization_id === specializationId);
|
||||
const abilityList = (specializationId: string) => data.abilities.filter((ability: any) =>
|
||||
ability.specialization_id === specializationId && Number(ability.active) === 1
|
||||
);
|
||||
const combatEffectList = (specializationId: string) => data.combatEffects.filter((effect: any) =>
|
||||
effect.specialization_id === specializationId && Number(effect.active) === 1
|
||||
);
|
||||
const nodesForFocus = (focusId: string) => data.nodes.filter((node: any) => node.focus_id === focusId);
|
||||
const activeNodesForFocus = (focusId: string) => nodesForFocus(focusId).filter((node: any) => Number(node.active) === 1);
|
||||
const archivedNodesForFocus = (focusId: string) => nodesForFocus(focusId).filter((node: any) => Number(node.active) === 0);
|
||||
@@ -139,6 +182,7 @@
|
||||
: String(focuses[0]?.id ?? '');
|
||||
configuringAbilities = false;
|
||||
nodeEditing = null;
|
||||
combatEffectEditing = null;
|
||||
};
|
||||
const selectFocus = (id: string) => {
|
||||
selectedFocusId = id;
|
||||
@@ -176,6 +220,17 @@
|
||||
iconUrl: item.iconUrl ?? ''
|
||||
};
|
||||
specializationEditing = focusEditing = nodeEditing = null;
|
||||
combatEffectEditing = null;
|
||||
};
|
||||
const editCombatEffect = (item: any) => {
|
||||
combatEffectEditing = {
|
||||
id: item.id,
|
||||
specializationId: item.specialization_id,
|
||||
name: item.name,
|
||||
descriptionTemplate: item.description_template,
|
||||
active: Number(item.active)
|
||||
};
|
||||
specializationEditing = focusEditing = abilityEditing = nodeEditing = null;
|
||||
};
|
||||
const editNode = (item: any) => {
|
||||
nodeEditing = {
|
||||
@@ -190,6 +245,19 @@
|
||||
rank: Number(rank.rank),
|
||||
description: rank.description,
|
||||
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 ?? '%')
|
||||
}))
|
||||
})),
|
||||
costs: { ...(rank.costs ?? {}) }
|
||||
})),
|
||||
active: Number(item.active),
|
||||
@@ -205,6 +273,19 @@
|
||||
nodeEditing?.ranks[index] ?? blankRank(index + 1)
|
||||
);
|
||||
};
|
||||
const addRankCombatEffect = (rank: RankEditor) => {
|
||||
rank.combatEffects = [...rank.combatEffects, blankRankCombatEffect()];
|
||||
};
|
||||
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 ?? ['', '', ''])];
|
||||
@@ -244,6 +325,9 @@
|
||||
iconResetToken = token;
|
||||
} else abilityEditing = null;
|
||||
}
|
||||
if (form?.combatEffectSaved) {
|
||||
combatEffectEditing = form.combatEffectMode === 'add' ? blankCombatEffect(selectedSpecializationId) : null;
|
||||
}
|
||||
if (form?.focusAbilitiesSaved) configuringAbilities = false;
|
||||
if (form?.nodeSaved) {
|
||||
if (form.nodeMode === 'add') {
|
||||
@@ -394,6 +478,27 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if combatEffectEditing}
|
||||
<div class="dashboard-editor specialization-combat-effect-editor">
|
||||
<div class="editor-title"><div><p class="eyebrow">{combatEffectEditing.id ? 'MODIFY COMBAT EFFECT' : 'NEW COMBAT EFFECT'}</p><h3>{combatEffectEditing.id ? combatEffectEditing.name : 'SHARED EFFECT DEFINITION'}</h3></div><button aria-label="Close combat effect editor" onclick={() => combatEffectEditing = null}>×</button></div>
|
||||
<PendingForm action="?/saveCombatEffect">
|
||||
<input type="hidden" name="id" value={combatEffectEditing.id}/>
|
||||
{#if combatEffectEditing.id}
|
||||
<input type="hidden" name="specializationId" value={combatEffectEditing.specializationId}/>
|
||||
{:else}
|
||||
<div class="field"><span>SPECIALIZATION</span><SearchableAssignment items={data.specializations} mode="single" compact fieldName="specializationId" subjectLabel="SPECIALIZATION" searchPlaceholder="Search specializations…" bind:value={combatEffectEditing.specializationId}/></div>
|
||||
{/if}
|
||||
<div class="form-grid combat-effect-definition-grid">
|
||||
<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>
|
||||
{#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>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if nodeEditing}
|
||||
<div class="dashboard-editor specialization-node-editor">
|
||||
<div class="editor-title"><div><p class="eyebrow">{nodeEditing.id ? 'MODIFY TREE NODE' : 'NEW TREE NODE'}</p><h3>{nodeEditing.id ? nodeEditing.name : 'UPGRADE DEFINITION'}</h3></div><button aria-label="Close tree node editor" onclick={() => nodeEditing = null}>×</button></div>
|
||||
@@ -426,6 +531,34 @@
|
||||
</header>
|
||||
<label class="field">RANK {rank.rank} DESCRIPTION<textarea bind:value={rank.description} maxlength="2400" required placeholder={`Describe the complete Rank ${rank.rank} effect.`}></textarea></label>
|
||||
<StructuredEffectEditor bind:effects={rank.effects} attributes={data.attributes} abilities={abilityList(String(focusById(nodeEditing.focusId)?.specialization_id ?? ''))} idPrefix={`node-rank-${rank.rank}`} addLabel={`ADD RANK ${rank.rank} EFFECT`}/>
|
||||
<div class="rank-combat-effects">
|
||||
<div class="selection-head"><div><span>CONDITIONAL + STACKING EFFECTS</span><small>BUILDER-CONTROLLABLE COMBAT MODEL</small></div><button type="button" class="secondary" onclick={() => addRankCombatEffect(rank)}>ADD CONDITIONAL EFFECT +</button></div>
|
||||
{#each rank.combatEffects as combatEffect, combatIndex}
|
||||
<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} showIndicator={false}/>
|
||||
<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>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{#if rank.rank === 2}
|
||||
<fieldset class="specialization-component-costs specialized">
|
||||
<legend>RANK 2 ELECTRONICS COMPONENTS · ONE OR MORE TYPES</legend>
|
||||
@@ -484,6 +617,7 @@
|
||||
<div class="specialization-workbench-actions specialization-mutation">
|
||||
<button class="secondary" onclick={() => editSpecialization(selectedSpecialization)}>EDIT SPECIALIZATION</button>
|
||||
<button class="secondary" onclick={() => { focusEditing = blankFocus(selectedSpecialization.id); specializationEditing = abilityEditing = nodeEditing = null; }}>ADD FOCUS +</button>
|
||||
<button class="secondary" onclick={() => { combatEffectEditing = blankCombatEffect(selectedSpecialization.id); specializationEditing = focusEditing = abilityEditing = nodeEditing = null; }}>ADD COMBAT EFFECT +</button>
|
||||
<button class="primary" onclick={() => { abilityEditing = blankAbility(selectedSpecialization.id); specializationEditing = focusEditing = nodeEditing = null; }}>ADD ABILITY +</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -574,6 +708,22 @@
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
<details class="specialization-record-drawer">
|
||||
<summary>
|
||||
<span>SHARED COMBAT EFFECT REGISTRY</span>
|
||||
<b>{combatEffectList(selectedSpecialization.id).length} RECORDS</b>
|
||||
</summary>
|
||||
<div>
|
||||
{#each data.combatEffects.filter((effect: any) => effect.specialization_id === selectedSpecialization.id) as effect}
|
||||
<article class:archived={Number(effect.active) === 0}>
|
||||
<div><strong>{effect.name}</strong><small>{effect.pending ? 'PENDING OVERSIGHT' : Number(effect.active) === 1 ? 'AVAILABLE TO FOCUS TREE RANKS' : 'ARCHIVED EFFECT'}</small><p>{effect.description_template}</p></div>
|
||||
{#if !effect.pending}<button class="secondary specialization-mutation" onclick={() => editCombatEffect(effect)}>EDIT →</button>{/if}
|
||||
</article>
|
||||
{:else}
|
||||
<p>No shared combat effects are registered for this specialization.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="specialization-tree-console">
|
||||
<header>
|
||||
|
||||
Reference in New Issue
Block a user