diff --git a/database/schema.sql b/database/schema.sql index afd4066..554217f 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -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, diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 2072103..84e0d3d 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index ce53166..d8c00fe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 22a7b79..6eb4220 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index dfe8aab..d885548 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index 809d926..566ae81 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -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(); diff --git a/scripts/isac-data-tables.mjs b/scripts/isac-data-tables.mjs index 6064e07..22b4168 100644 --- a/scripts/isac-data-tables.mjs +++ b/scripts/isac-data-tables.mjs @@ -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' ]; diff --git a/src/app.css b/src/app.css index 86d7aec..afb4781 100644 --- a/src/app.css +++ b/src/app.css @@ -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} diff --git a/src/hooks.server.ts b/src/hooks.server.ts index a842249..49956f9 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -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) { diff --git a/src/lib/builder/loadout-combat.ts b/src/lib/builder/loadout-combat.ts new file mode 100644 index 0000000..671da9d --- /dev/null +++ b/src/lib/builder/loadout-combat.ts @@ -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(); + const sources = new Map>(); + 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(); + 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(); + 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}` + }; + }); +} diff --git a/src/lib/builder/loadout.ts b/src/lib/builder/loadout.ts index 533adea..303db5c 100644 --- a/src/lib/builder/loadout.ts +++ b/src/lib/builder/loadout.ts @@ -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 }; +export type SpecializationSelection = { + focusId: string; + nodeRanks: Record; + effectStates: Record; + combatProfile: CombatProfile; + unassignedChips: ChipSelection[]; +}; export type LoadoutDraft = { - version: 1; + version: 2; buildName: string; gear: Record; 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 & { + 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; + 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; diff --git a/src/lib/calculations/combat.test.ts b/src/lib/calculations/combat.test.ts new file mode 100644 index 0000000..a43646b --- /dev/null +++ b/src/lib/calculations/combat.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { calculateCombat, type CombatCalculationInput } from './combat.js'; + +const base = (overrides: Partial = {}): 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' + ])); + }); +}); diff --git a/src/lib/calculations/combat.ts b/src/lib/calculations/combat.ts new file mode 100644 index 0000000..e7b7921 --- /dev/null +++ b/src/lib/calculations/combat.ts @@ -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; +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>>; +}; +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, + 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, + 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, + 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, 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 = { + 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) + }; + }) + }; +} diff --git a/src/lib/components/ProposalChangePreview.svelte b/src/lib/components/ProposalChangePreview.svelte index 6041274..e6e08fb 100644 --- a/src/lib/components/ProposalChangePreview.svelte +++ b/src/lib/components/ProposalChangePreview.svelte @@ -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 | 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 @@ - {:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveFocusAbilities','saveNode'].includes(action)} + {:else if module === 'calculations'} +
+
+
CURRENT LIVE · {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}{calculationRecord ? 'BEFORE' : 'NO LIVE RECORD'}
+ {#if calculationRecord}
NAME{shown(calculationRecord.name)}
STATE{shown(calculationRecord.status)}
{#if action === 'saveRevision'}
STAGES{(currentSnapshot?.stages as any[])?.length ?? 0}
ATTRIBUTE PROFILES{(currentSnapshot?.profiles as any[])?.length ?? 0}
{/if}
{:else}
NEW {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}No live registry record will be replaced.
{/if} +
+
+
PROPOSED · {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}AFTER
+
NAME{shown(proposed.name)}
{#if action === 'saveRevision'}
STAGES{proposedCalculationStages.length}
ATTRIBUTE PROFILES{proposedCalculationProfiles.length}
BASE REVISION{shown(proposed.basedOnRevisionId)}
{:else}
STATE{shown(proposed.status)}
MEASURED WEAPON{proposed.selectedWeapon === '1' ? 'SECONDARY' : 'PRIMARY'}
CORE OBSERVATIONS{['bodyHit','criticalBodyHit','headshot','criticalHeadshot','magazineSeconds','reloadSeconds'].filter((field) => proposed[field]).length} / 6
{/if}
+ {#if action === 'saveRevision'}
{#each proposedCalculationStages as stage}
{stage.label}{stage.operator?.replaceAll('_',' ').toUpperCase()}

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

{/each}
{/if} +
+
+ {:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveCombatEffect','saveFocusAbilities','saveNode'].includes(action)}
CURRENT LIVE · {specializationKind}{specializationRecord ? 'BEFORE' : 'NO LIVE RECORD'}
@@ -221,9 +243,10 @@
{#each proposedRanks as rank}
-
RANK {rank.rank}{rank.effects?.length ?? 0} EFFECTS
+
RANK {rank.rank}{rank.effects?.length ?? 0} STATIC · {rank.combatEffects?.length ?? 0} CONDITIONAL

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

{#each rank.effects ?? [] as effect}{effectSummary(effect)}{/each} + {#each rank.combatEffects ?? [] as effect}{effect.effectId} · {effect.triggerCount} HITS / {effect.triggerWindowSeconds}s · {effect.maxStacks} STACKS · {effect.modifiers?.length ?? 0} MODIFIERS{/each} {Object.entries(rank.costs ?? {}).filter(([, quantity]) => Number(quantity) > 0).map(([resource, quantity]) => `${resource} × ${quantity}`).join(' · ') || 'CREDITS ONLY'}
{/each} diff --git a/src/lib/components/SegmentedChoice.svelte b/src/lib/components/SegmentedChoice.svelte index 4ce915c..59a1c33 100644 --- a/src/lib/components/SegmentedChoice.svelte +++ b/src/lib/components/SegmentedChoice.svelte @@ -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)} /> diff --git a/src/lib/components/builder/CombatAssumptions.svelte b/src/lib/components/builder/CombatAssumptions.svelte new file mode 100644 index 0000000..eeb1743 --- /dev/null +++ b/src/lib/components/builder/CombatAssumptions.svelte @@ -0,0 +1,50 @@ + + +
+
+
+

COMBAT PROFILE

+

Encounter assumptions

+
+ MODELED, NOT LIVE COMBAT +
+

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

+
+ update('encounterSeconds', value)} /> + update('hitRate', value)} /> + update('headshotRate', value)} /> + update('firingActivity', value)} /> +
+
+ + diff --git a/src/lib/components/builder/ConditionalEffectCard.svelte b/src/lib/components/builder/ConditionalEffectCard.svelte new file mode 100644 index 0000000..8e6d218 --- /dev/null +++ b/src/lib/components/builder/ConditionalEffectCard.svelte @@ -0,0 +1,97 @@ + + +
+
+
+

CONDITIONAL EFFECT

+

{name}

+ {#if description}{description}{/if} +
+ {appliedState} +
+
+ {#each facts as fact}{fact}{/each} +
+
+ +
+ {#if effectState.mode === 'encounter'} +
+ + {#if effectState.overrides}{/if} +
+ {#if advanced} +
+ updateOverride('hitRate', value)} /> + updateOverride('firingActivity', value)} /> +
+ {/if} + {/if} +
+ + diff --git a/src/lib/components/builder/WeaponCalculationResult.svelte b/src/lib/components/builder/WeaponCalculationResult.svelte new file mode 100644 index 0000000..bbfcdac --- /dev/null +++ b/src/lib/components/builder/WeaponCalculationResult.svelte @@ -0,0 +1,72 @@ + + +
+
+

{result.weaponLabel}

{result.weaponName}

+ {result.status === 'complete' ? 'CALCULATED' : 'INSUFFICIENT DATA'} +
+ {#if result.status === 'insufficient'} +
Calculation unavailableComplete the following required data:
    {#each result.missing ?? [] as item}
  • {item}
  • {/each}
+ {:else} +
+ BODY HIT{format(result.bodyHit)}CRITICAL BODY{format(result.criticalBodyHit)} + HEADSHOT{format(result.headshot)}CRITICAL HEADSHOT{format(result.criticalHeadshot)} +
+
BURST DPS{format(result.burstDps)}SUSTAINED DPS{format(result.sustainedDps)}ENCOUNTER DPS{format(result.encounterDps)}
+ {#if result.effectSummaries?.length} +
+
CONDITIONAL STACK TELEMETRY{result.effectSummaries.length} EFFECTS
+ {#each result.effectSummaries as effect} +
+ {effect.name} + {effect.mode.toUpperCase()} + AVG {effect.averageStacks.toFixed(1)} / {effect.maxStacks} + PEAK {effect.maxStacksReached} · END {effect.stacksAtEnd} + {effect.mode === 'off' ? 'EXCLUDED' : effect.buildUpSeconds === null ? 'MAX NOT REACHED' : `MAX IN ${effect.buildUpSeconds.toFixed(1)}s`} + {effect.maintained ? 'MAINTAINED' : 'NOT MAINTAINED'} +
+ {/each} +
+ {/if} + {/if} +
MODELED, BEFORE ENEMY MITIGATION{#if result.revisionLabel}{result.revisionLabel}{/if}
+
+ + diff --git a/src/lib/server/audit.ts b/src/lib/server/audit.ts index 9a5e350..99ad538 100644 --- a/src/lib/server/audit.ts +++ b/src/lib/server/audit.ts @@ -41,6 +41,7 @@ export async function auditRecord(table: string, id: string): Promise>> = { 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 ({ + ...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 + } + }; +} diff --git a/src/routes/builder/+page.server.ts b/src/routes/builder/+page.server.ts index 708e2c5..51af95c 100644 --- a/src/routes/builder/+page.server.ts +++ b/src/routes/builder/+page.server.ts @@ -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 + } }; }; diff --git a/src/routes/builder/+page.svelte b/src/routes/builder/+page.svelte index 0a8c85f..0d1e07c 100644 --- a/src/routes/builder/+page.svelte +++ b/src/routes/builder/+page.svelte @@ -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(untrack(() => data.initialBuild?.loadout ?? createDraft())); + let draft = $state(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(); + 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(); - const sources = new Map>(); - 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(); - 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(); - 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'}VIEW SHARE PAGE →{/if}
{/if} -
LIVE CALCULATION12 EQUIPMENT POSITIONS{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS
+
LIVE CALCULATION18 EQUIPMENT POSITIONS{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS
{#if message}

{message}

{/if} {#if form?.buildError}{/if} {#if !data.gear.length && !data.weapons.length && !data.chipSets.length && !data.protocols.length}
ISAC DATA LINK EMPTYAdd active equipment definitions in ISAC to populate this workbench.
{/if}
-
EQUIPMENT{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
+
EQUIPMENT{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
{#each ['PRIMARY', 'SECONDARY'] as label, index}{@const item = weaponItem(index)}{/each}
{#each gearSlots as slot}{@const item = gearItem(slot)}{/each}
-
{#each [0,1,2] as index}{@const item = chipItem(index)}{/each}
+
- {#if activeParts[0] === 'gear'} + {#if activeParts[0] === 'specialization'} +
AGENT CLASS

SPECIALIZATION FOCUS

{selectedFocus ? `${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS` : 'UNASSIGNED'}
+ + {#if draft.specialization.unassignedChips.length} +
UNRESOLVED LEGACY SKILL CHIPS{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.
+ {/if} + {#if selectedFocus} +
{selectedFocus.specialization.name}

{selectedFocus.name}

{selectedFocus.description || 'No focus description has been entered.'}

{Number(selectedFocus.builder_ready) === 1 ? 'BUILDER READY' : 'ISAC DATA INCOMPLETE'}
+
+ {#each [0,1,2] as abilityIndex} + {@const ability = selectedFocus.abilities[abilityIndex]} +
+
ABILITY {abilityIndex + 1}{ability?.name ?? 'NOT CONFIGURED'}
{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3 CHIPS
+

{ability?.description ?? 'ISAC requires this class ability before compatible Skill Chips can be equipped.'}

+
{#each [0,1,2] as chipSlot}{@const chipIndex = abilityIndex * 3 + chipSlot}{@const item = chipItem(chipIndex)}{/each}
+
+ {/each} +
+
POINTS {selectedSpecializationPoints} / {data.specializationProgression.availablePoints}CREDITS {selectedSpecializationCosts.credits.toLocaleString()}{#each selectedSpecializationCosts.resources as resource}{String(resource[0]).replace('resource-electronics-','').replaceAll('-',' ').toUpperCase()} × {resource[1]}{/each}
+
+
FOCUS UPGRADE TREEPREVIOUS ROWS REQUIRE EVERY AVAILABLE RANK 1 + 2 BEFORE ADVANCING
{selectedFocus.nodes.length} NODES
+ {#each [...new Set(selectedFocus.nodes.map((node: any) => Number(node.row_number)))] as row} +
ROW {String(row).padStart(2, '0')}{row === 1 ? 'ENTRY ROW' : 'LOCKED BY PREVIOUS ROWS'}
+ {#each selectedFocus.nodes.filter((node: any) => Number(node.row_number) === row) as node} +
{node.name}{node.description_template}
({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}/>
+ {/each} +
+ {/each} +
+ + {#if selectedCombatEffects.length} +
CONDITIONAL EFFECT PROFILESCHOOSE HOW EACH UNLOCKED EFFECT CONTRIBUTES TO DPS
{selectedCombatEffects.length} EFFECTS
{#each selectedCombatEffects as effect} `${unitValue(modifier.value_per_stack,modifier.stat_attribute_id)} ${attribute(modifier.stat_attribute_id)?.name ?? 'ATTRIBUTE'} PER STACK`)]} appliedState={draft.specialization.effectStates[effect.id]?.mode === 'peak' ? `APPLIED: ${effect.max_stacks}/${effect.max_stacks}` : draft.specialization.effectStates[effect.id]?.mode === 'encounter' ? `APPLIED: 0→${effect.max_stacks}/${effect.max_stacks}` : 'NOT APPLIED'} bind:effectState={draft.specialization.effectStates[effect.id]}/>{/each}
+ {/if} + {/if} + {:else if activeParts[0] === 'gear'} {@const slot = activeParts[1]}{@const selection = draft.gear[slot]}{@const item = gearItem(slot)}
GEAR SLOT

{slot.toUpperCase()}

{item ? `TIER ${item.tier} · BUILD LEVEL ${selection.level}` : 'UNASSIGNED'}
entry.slot === slot)} bind:value={selection.itemId} onchange={(id) => configureGear(slot,id)} label="ITEM DEFINITION"/> @@ -429,9 +479,10 @@ {#if item.baseBonuses.length}
{#each item.baseBonuses as bonus}{attribute(bonus.stat_attribute_id)?.name}{unitValue(bonus.value,bonus.stat_attribute_id)}{/each}
{/if} {/if} {:else if activeParts[0] === 'chip'} - {@const index = Number(activeParts[1])}{@const chip = draft.chips[index]}{@const item = chipItem(index)} -
SKILL SYSTEM

SKILL CHIP 0{index + 1}

{chip.quality.toUpperCase()}
- 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]} +
ABILITY {abilityIndex + 1} · CHIP {(index % 3) + 1}

{ability?.name?.toUpperCase() ?? 'SKILL CHIP'}

{chip.quality.toUpperCase()}
+ + {#if ability} entry.specialization_ability_id === ability.id)} bind:value={chip.setId} onchange={(id) => configureChip(index,id)} label="ABILITY-COMPATIBLE SKILL CHIP SET"/>{:else}

Select a focus with three configured class abilities before equipping Skill Chips.

{/if} {#if item}
item.primaryStatIds.includes(entry.id))} bind:value={chip.primary.statId} onchange={(id) => setRollStat(chip.primary,id,'chip')} label="PRIMARY ATTRIBUTE"/>
{#each chip.secondary as roll,index}
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}`}/>
{/each}
{/if} {:else} {@const protocol = protocolItem()} @@ -444,6 +495,7 @@