Author SHA1 Message Date
jb 6af38024fa feat: add catalog weapon throughput baseline 2026-08-07 13:16:12 -04:00
jb 4777a3abd5 feat: refine builder skill chip flows
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 44s
Validate and deploy staging / deploy (push) Successful in 1m14s
2026-08-06 01:00:15 -04:00
jb 46f64d37b6 fix: serialize article autosaves
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 33s
Validate and deploy staging / deploy (push) Successful in 1m11s
2026-08-04 12:45:29 -04:00
jb 7ca6cb8458 Updating mechanics for OS protocols
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 33s
Validate and deploy staging / deploy (push) Successful in 1m9s
2026-08-03 16:12:20 -04:00
jb 57068b61cd feat: expand ISAC content modeling
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 28s
Validate and deploy staging / deploy (push) Successful in 1m3s
2026-08-03 01:23:20 -04:00
jb d9924a95b9 (feat) Discord build share 2026-08-02 12:00:30 -04:00
jb d09b6220ee feat: defer advanced build calculations
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 27s
Validate and deploy staging / deploy (push) Successful in 54s
2026-08-02 03:07:12 -04:00
jb c61b9832dd feat: add footer support link 2026-08-02 01:53:29 -04:00
126 changed files with 14856 additions and 2887 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ ENV NODE_ENV=production \
TDR_GAME_ICON_STORAGE_PATH=/app/data/game-icons
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl gosu \
&& apt-get install -y --no-install-recommends ca-certificates curl fonts-dejavu-core gosu \
&& useradd --uid 10001 --gid node --no-create-home --shell /usr/sbin/nologin shd-runtime \
&& case "$TARGETARCH" in \
amd64) infisical_sha256=53abc96a3bc872ade81efe156275bd30c53f9ee8b52965b0c9cb320a9c814fbc ;; \
+59 -30
View File
@@ -769,15 +769,12 @@ CREATE TABLE IF NOT EXISTS stat_attributes (
weapon_min REAL,
weapon_max REAL,
weapon_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50),
skill_chip_min REAL,
skill_chip_max REAL,
cap_value REAL,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK((gear_min IS NULL AND gear_max IS NULL) OR (gear_min IS NOT NULL AND gear_max IS NOT NULL AND gear_min <= gear_max)),
CHECK((weapon_min IS NULL AND weapon_max IS NULL) OR (weapon_min IS NOT NULL AND weapon_max IS NOT NULL AND weapon_min <= weapon_max)),
CHECK((skill_chip_min IS NULL AND skill_chip_max IS NULL) OR (skill_chip_min IS NOT NULL AND skill_chip_max IS NOT NULL AND skill_chip_min <= skill_chip_max)),
CHECK(cap_value IS NULL OR cap_value >= 0)
);
CREATE UNIQUE INDEX IF NOT EXISTS stat_attributes_name_unit_nocase_idx ON stat_attributes(name COLLATE NOCASE, unit);
@@ -906,6 +903,7 @@ CREATE TABLE IF NOT EXISTS conditional_effect_outcomes (
delivery_mode TEXT NOT NULL DEFAULT 'instant' CHECK(delivery_mode IN ('instant','continuous')),
delivery_duration_seconds REAL CHECK(delivery_duration_seconds IS NULL OR delivery_duration_seconds>0),
targeting_json TEXT,
probability_percent REAL CHECK(probability_percent IS NULL OR (probability_percent>=0 AND probability_percent<=100)),
min_state_value REAL,
max_state_value REAL,
description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1),
@@ -1168,14 +1166,30 @@ CREATE TABLE IF NOT EXISTS skill_chip_two_piece_bonuses (
CHECK(max_value >= min_value)
);
CREATE TABLE IF NOT EXISTS skill_chip_primary_options (
skill_chip_set_id TEXT NOT NULL REFERENCES skill_chip_sets(id) ON DELETE CASCADE,
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
PRIMARY KEY(skill_chip_set_id, stat_attribute_id)
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
);
CREATE TABLE IF NOT EXISTS skill_chip_primary_quality_values (
stat_attribute_id TEXT NOT NULL,
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
min_value REAL NOT NULL,
max_value REAL NOT NULL,
PRIMARY KEY(stat_attribute_id,quality),
FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_primary_options(stat_attribute_id) ON DELETE CASCADE,
CHECK(max_value>=min_value),
CHECK(min_value>=0)
);
CREATE TABLE IF NOT EXISTS skill_chip_secondary_options (
skill_chip_set_id TEXT NOT NULL REFERENCES skill_chip_sets(id) ON DELETE CASCADE,
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
PRIMARY KEY(skill_chip_set_id, stat_attribute_id)
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
);
CREATE TABLE IF NOT EXISTS skill_chip_secondary_quality_values (
stat_attribute_id TEXT NOT NULL,
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
min_value REAL NOT NULL,
max_value REAL NOT NULL,
PRIMARY KEY(stat_attribute_id,quality),
FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_secondary_options(stat_attribute_id) ON DELETE CASCADE,
CHECK(max_value>=min_value),
CHECK(min_value>=0)
);
CREATE TABLE IF NOT EXISTS skill_chip_talents (
id TEXT PRIMARY KEY,
@@ -1232,19 +1246,21 @@ CREATE TABLE IF NOT EXISTS weapon_mod_quality_values (
CHECK(primary_level_20_max>=primary_level_1_max)
);
CREATE TABLE IF NOT EXISTS weapon_mod_secondary_options (
weapon_mod_id TEXT NOT NULL REFERENCES weapon_mods(id) ON DELETE CASCADE,
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
PRIMARY KEY(weapon_mod_id,stat_attribute_id)
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
);
CREATE TABLE IF NOT EXISTS weapon_mod_secondary_quality_values (
weapon_mod_id TEXT NOT NULL,
stat_attribute_id TEXT NOT NULL,
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
min_value REAL NOT NULL,
max_value REAL NOT NULL,
PRIMARY KEY(weapon_mod_id,stat_attribute_id,quality),
FOREIGN KEY(weapon_mod_id,stat_attribute_id) REFERENCES weapon_mod_secondary_options(weapon_mod_id,stat_attribute_id) ON DELETE CASCADE,
CHECK(max_value>=min_value)
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
level_1_min REAL NOT NULL,
level_1_max REAL NOT NULL,
level_20_min REAL NOT NULL,
level_20_max REAL NOT NULL,
PRIMARY KEY(stat_attribute_id,quality),
FOREIGN KEY(stat_attribute_id) REFERENCES weapon_mod_secondary_options(stat_attribute_id) ON DELETE CASCADE,
CHECK(level_1_max>=level_1_min),
CHECK(level_20_max>=level_20_min),
CHECK(level_20_min>=level_1_min),
CHECK(level_20_max>=level_1_max)
);
CREATE TABLE IF NOT EXISTS weapon_mod_exotic_attributes (
id TEXT PRIMARY KEY,
@@ -1292,19 +1308,21 @@ CREATE TABLE IF NOT EXISTS gear_mod_quality_values (
CHECK(primary_level_20_max>=primary_level_1_max)
);
CREATE TABLE IF NOT EXISTS gear_mod_secondary_options (
gear_mod_id TEXT NOT NULL REFERENCES gear_mods(id) ON DELETE CASCADE,
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
PRIMARY KEY(gear_mod_id,stat_attribute_id)
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
);
CREATE TABLE IF NOT EXISTS gear_mod_secondary_quality_values (
gear_mod_id TEXT NOT NULL,
stat_attribute_id TEXT NOT NULL,
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
min_value REAL NOT NULL,
max_value REAL NOT NULL,
PRIMARY KEY(gear_mod_id,stat_attribute_id,quality),
FOREIGN KEY(gear_mod_id,stat_attribute_id) REFERENCES gear_mod_secondary_options(gear_mod_id,stat_attribute_id) ON DELETE CASCADE,
CHECK(max_value>=min_value)
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
level_1_min REAL NOT NULL,
level_1_max REAL NOT NULL,
level_20_min REAL NOT NULL,
level_20_max REAL NOT NULL,
PRIMARY KEY(stat_attribute_id,quality),
FOREIGN KEY(stat_attribute_id) REFERENCES gear_mod_secondary_options(stat_attribute_id) ON DELETE CASCADE,
CHECK(level_1_max>=level_1_min),
CHECK(level_20_max>=level_20_min),
CHECK(level_20_min>=level_1_min),
CHECK(level_20_max>=level_1_max)
);
CREATE TABLE IF NOT EXISTS specialization_progression_config (
id TEXT PRIMARY KEY CHECK(id='default'),
@@ -1404,6 +1422,17 @@ CREATE TABLE IF NOT EXISTS specialization_ability_attacks (
CHECK(charge_policy<>'none' OR charge_count=1)
);
CREATE INDEX IF NOT EXISTS specialization_abilities_specialization_idx ON specialization_abilities(specialization_id,name);
CREATE TABLE IF NOT EXISTS specialization_ability_skill_chip_links (
ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
compatible_ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
PRIMARY KEY(ability_id,compatible_ability_id),
CHECK(ability_id<compatible_ability_id)
);
CREATE INDEX IF NOT EXISTS specialization_ability_skill_chip_links_compatible_idx
ON specialization_ability_skill_chip_links(compatible_ability_id,ability_id);
-- db-migrate.mjs installs insert/update triggers for active same-specialization
-- endpoints and one-pair-per-ability enforcement. Trigger bodies cannot live in
-- this semicolon-delimited schema file.
CREATE TABLE IF NOT EXISTS specialization_ability_effects (
id TEXT PRIMARY KEY,
ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
@@ -1628,7 +1657,7 @@ INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator
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_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node', 'weapon_mod_slot')),
entity_id TEXT NOT NULL,
storage_key TEXT NOT NULL UNIQUE,
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
+278 -107
View File
@@ -341,40 +341,58 @@ squeezed beside long module headings on mobile.
### Command Grid builder
The production builder uses Command Grid as its loadout workbench. Keep the
entire build visible in a dense matrix while one connected editor configures the
selected cell. The full-width Specialization and Focus cell is the root choice.
Its three supplied abilities appear directly below it, followed by two weapons,
six gear positions, and one full-width OS Protocol.
entire build visible in a compact matrix while one connected editor configures
the selected cell. The editor uses two lanes when its container has enough
width. Equipment identity and base values occupy the first lane; allocations,
rolls, Talents, and upgrades occupy the second. The lanes stack when either
would become too narrow. The full-width Specialization and Focus cell is the
root choice. Its three supplied abilities appear directly below it, followed by
two weapons, six gear positions, and one full-width OS Protocol.
Matrix cells summarize selected Talent names, Attribute names and values, Brand
Sets, or Skill Chips. Do not replace those choices with configuration counts.
Selecting an ability opens its three attached Skill Chip positions. Selecting
equipment opens the searchable real-data catalog and its compatible
configuration controls. Do not display a prototype option that the current
loadout schema cannot save.
Matrix cells show the slot and equipped item at rest. The selected cell reveals
its compact Talent, Attribute, Brand Set, or Skill Chip summary. This
progressive disclosure keeps the matrix scannable without replacing configured
choices with counts. Selecting an ability opens its three attached Skill Chip
positions. Selecting equipment opens the searchable real-data catalog and its
compatible configuration controls. Large Talent pools show the selected Talent
at rest and open their search grid through an explicit Change action. Do not
display a prototype option that the current loadout schema cannot save.
Calculation readiness belongs at the point of resolution. Mark an incomplete
Builder cells use one leading icon track. Specializations, abilities, Skill
Chips, and OS Protocols use registered artwork; weapons and gear use static
category or slot fallbacks when no item artwork exists. Skill Chip slots keep
the icon beside the set name and quality so empty and configured slots share one
scan path. Visible builder labels stay short; full validation text remains
available through the cell's accessible name or title.
Each equipped gear or weapon Talent exposes two adjacent icon choices for Tier I
and Tier II. Both choices reuse the Talent artwork; Tier II applies a restrained
warm recolor so players can distinguish the pair before reading the labels.
Every tile retains `T1` or `T2`, its full tier name, and selected or unavailable
text. Disable Tier II when the canonical Talent has no authored Tier II data.
Weapon equipment tier and Talent tier are independent selections.
Equipment readiness belongs at the point of resolution. Mark an incomplete
matrix cell with a compact red text indicator and status rail, then let the cell
open the editor that resolves it. The live calculation rail summarizes blocked
output in one compact row. Do not repeat every missing slot in a large error
panel. Keep system-owned blockers, such as an unpublished calculation profile,
visible in that compact summary because the player cannot resolve them from a
loadout cell.
open the editor that resolves it. The live totals rail summarizes deterministic
equipped-item totals. Separate panels label conditional Talent outcomes, weapon
and Skill DPS, encounter assumptions, activation profiles, rotations, and
simulation as `COMING SOON`; they do not report calculation-profile blockers as
current player errors.
The builder uses three workbench columns on wide screens, moves the live summary
below the matrix and editor at intermediate widths, and stacks all three regions
on narrow screens. Every grid track and editor child must accept content
shrinkage. At 560px and below, stack attribute picker/value pairs and section
headings; keep the command actions in two equal columns. Validation markers use
short visible labels while their full messages remain available to assistive
technology and pointer users.
The builder uses two primary workbench columns on wide screens: the compact
matrix and the two-lane editor. The live summary spans both columns below them.
Narrow screens stack the matrix, editor, and summary. Every grid track and
editor child must accept content shrinkage. At 560px and below, stack Attribute
picker/value pairs and section headings; keep the command actions in two equal
columns. Validation markers use short visible labels while their full messages
remain available to assistive technology and pointer users.
Use `--shd-builder-canvas` for recessed equipment fields. Use
`--shd-builder-selection` with `--shd-builder-selection-border` for the selected
cell. Preserve button semantics, `aria-pressed`, focus-visible outlines, pressed
feedback, and textual readiness states. At narrower desktop widths, keep the
matrix and editor together and move live results below them. On mobile, stack
the matrix, editor, and live results in that order.
feedback, and textual readiness states. On mobile, stack the matrix, editor, and
live results in that order.
The command header contains the required build name, local and account save
actions, account visibility, and optional sharing notes. Sharing notes include a
@@ -1572,13 +1590,42 @@ an icon in every option.
### Skill Chip data console
Skill Chip editing uses the dense ISAC staged-definition pattern: identity and
icon, 2-piece numeric bonus, mutually exclusive primary and secondary roll
pools, then 3-piece talent. Superior blue and High-end yellow identify the
supported qualities; reserve orange for actions and the current interactive
state. A four-step segmented preview shows the linear zero-to-three High-end
interpolation without duplicate values. At narrow widths, stack roll-pool and
talent columns, use a two-by-two preview grid, and retain 44-pixel targets.
Place two shared configuration cards above the Skill Chip definition registry.
The `SHARED PRIMARY PROFILE` card contains the fixed Skill Duration, Skill
Radius, Skill Cooldown Recovery, and Skill Intensity rows, with direct minimum
and maximum fields for Standard, Specialized, Superior, and High-end. Its
summary shows the configured count for each quality and the `NOT YET DEFINED`
calculation state. The `SHARED SECONDARY POOL` card contains repeatable
searchable Attribute rows with direct minimum and maximum fields for
Specialized, Superior, and High-end. Its summary shows each quality's fixed
capacity and the shared option count. Each card opens its own editor and closes
that editor after a successful save. Validation keeps the affected editor open
with its values intact. Successful saves append
`game_data/skill_chip_primary_profile.updated` or
`game_data/skill_chip_secondary_pool.updated`.
State the fixed secondary capacities in the pool card and editor: Standard zero,
Specialized one, Superior two, and High-end two. Skill Chip fields never show an
item-level control or level-based preview. Quality labels use the global green,
blue, purple, and yellow treatments; orange remains reserved for actions and
selection. On narrow screens, profile summaries stack, endpoint matrices scroll
inside their cards, and actions retain 44-pixel targets.
Definition editors follow the dense staged pattern: ability assignment, identity
and icon, one per-set 2-piece bonus, then the optional 3-piece Talent. Place the
Talent assignment control before one disclosure that contains the entire Talent
workspace: identity, description, quality-mix preview, and
`MechanicProgramEditor`. The disclosure summary shows the configured Talent name
or `TALENT WORKSPACE`, the quality-scaled outcome count, and an explicit
`EXPAND` or `COLLAPSE` action. Existing Talents start collapsed when an operator
opens a definition. Assigning a Talent or receiving a Talent validation error
opens the workspace. Native disclosure semantics expose the expanded state, keep
draft state mounted while collapsed, and provide a 44-pixel mobile target.
Every equipped chip quality counts toward the two-piece threshold. The set-bonus
preview uses four steps for zero through three equipped High-end chips and the
formula `minimum + (maximum − minimum) × h / 3`; only High-end chips increase
`h`. Do not duplicate calculated values as editable fields.
### ISAC record identity controls
@@ -1707,10 +1754,10 @@ cards and augment editor cards reduce to a single column while retaining
The builder is a three-rail tactical workbench: compact equipment positions on
the left, the currently selected item editor in the center, and a sticky live
calculation summary on the right. At medium widths the summary becomes a
full-width lower rail; on mobile all three rails stack, with equipment first,
editing second, and results third. Equipment positions remain explicit even when
empty so loadout completeness is always visible.
totals summary on the right. At medium widths the summary becomes a full-width
lower rail; on mobile all three rails stack, with equipment first, editing
second, and results third. Equipment positions remain explicit even when empty
so players can see loadout completeness.
Use the shared `BuilderPicker` for builder catalogs and Attribute pools. It
combines an always-visible current selection with a searchable overlay, icon or
@@ -1721,11 +1768,11 @@ such as the single-exotic-weapon rule are explained rather than hidden.
Numeric instance values display their authoritative minimum and maximum beside
the field and clamp to that range on completion. Always-on item rolls, fixed
bonuses, inherited profiles, enabled augments, and activated set bonuses feed
the live totals immediately. Conditional mechanics state their calculation mode
and readiness. Maximum Potential uses their best-case state; Custom Scenario
uses the source-defined advanced assumptions saved with the loadout. Exclude a
mechanic that lacks a required assumption and label it `REQUIRES ASSUMPTIONS`.
This distinction must remain clear without relying on color.
the live totals immediately. Current Builder and shared Build pages stop at
these deterministic equipped-item totals. Conditional Talent outcomes, weapon
and Skill DPS, encounter assumptions, activation profiles, rotations, and
simulation remain planned. Render those capabilities as separate `COMING SOON`
panels, with visible text rather than color alone.
Loadout summaries name selected Talents, Attributes and their exact values,
nodes, and Skill Chips. Do not replace these selections with configured item
@@ -1808,6 +1855,15 @@ same `weaponBaseRange` curve as the builder; never label the configured base
endpoints as level-50 values. Weapon Talent 1 and Talent 2 pools use separate
clickable icon rails and the standard Talent quick view.
Show each weapon's named Mod slots and slot pictograms between its performance
facts and Talent pools. Native T0 weapons display the complete T1/T2 slot set.
Mark the slot that is absent at T0 with the blue information state and an
explicit `T1+` label so the unlock does not rely on color alone. Other slots use
the neutral telemetry treatment. If ISAC has no slot data, show
`MOD SLOT DATA NOT CONFIGURED`. Do not show the weapon-category bonus count as
`BASE BONUSES`; that identical count does not help players compare weapon
definitions.
Record cards may end with contextual actions that connect related public
registries. Actions use the compact orange tactical-button treatment and encode
their destination filter in the URL. Brand cards provide a “find Gear” action
@@ -1920,10 +1976,33 @@ selector:
- `PUBLIC` is accessible by direct link and included in the public archive.
Keep visibility adjacent to the account-save action and explain its selected
state in plain language. Show a share-page action only after a non-private build
has been saved. Public and unlisted build pages are read-only tactical
summaries; owner editing returns to the builder. On narrow layouts, persistence
controls and shared-loadout columns stack vertically.
state in plain language. Show Build Uplink after an Agent saves a Build. Public
and unlisted Builds expose its active controls; private Builds show the locked
state. Public and unlisted Build pages are read-only tactical summaries; owner
editing returns to the builder.
Build Uplink uses a compact tactical control surface. Use one bordered dark
panel with a raised telemetry header, a textual `PUBLIC LINK READY` or
`UNLISTED LINK READY` state, and a small orange signal marker. The primary
`SHARE BUILD` action opens the native Web Share sheet when the browser supports
it. Keep `COPY LINK` visible as a separate explicit action for every eligible
Build. When Web Share is unavailable, promote Copy Link to the primary action
instead of presenting a control that cannot run. Supporting copy may state that
Discord renders the Build preview after an Agent posts the link. It must not
imply that SHD.build posts to Discord or operates a bot.
Private and unsaved Builds use the locked Uplink state. Show `PRIVATE BUILD` or
`UNSAVED BUILD`, `PUBLIC LINK UNAVAILABLE`, and a short instruction to change
visibility or save. Render no active Share or Copy Link control in that state.
Do not expose a crawler-preview action or suggest that owner access makes a
private Build shareable.
Uplink actions retain textual pending, success, cancellation, unsupported, and
failure feedback. They use at least 44-pixel targets, orange focus-visible
treatment, pressed feedback, and disabled duplicate-action protection. Stack
Share above Copy Link at phone widths and remove pressed motion under
`prefers-reduced-motion`. On narrow layouts, persistence controls and
shared-loadout columns also stack vertically.
### Tiered compatibility editors
@@ -1947,18 +2026,26 @@ mode with weapon icons and retained accessible names. Show configured versus
required slot counts in text. Disabled tiers remain visibly unavailable and must
not submit slot assignments.
Quality-scaled registry editors use a compact matrix when the same numeric shape
repeats across all four quality levels. Apply global quality colors only to the
quality labels, retain textual quality names, and keep numeric fields neutral.
On narrow screens the matrix may scroll horizontally while its surrounding
editor and previews stack.
Regular Weapon Mod definitions use a compact Level 1 and Level 20 primary-range
matrix for Standard, Specialized, Superior, and High-end quality. Apply global
quality colors only to the quality labels, retain textual quality names, and
keep numeric fields neutral. On narrow screens the matrix may scroll
horizontally while its surrounding editor and previews stack. Primary core type
remains builder-selected and appears as the three always-available core icons
rather than a persisted ISAC choice. Individual regular Weapon Mod editors also
retain triggerless Mod Effects with one or two Attribute references and fixed
per-quality values.
When two item slots draw from the same canonical option pool, edit that pool
once rather than rendering two duplicated selectors. Weapon Mods use a
repeatable secondary-attribute row: one searchable attribute reference plus four
quality range pairs. Show that the builder selects two distinct options from the
pool. Primary core type is likewise builder-selected and is displayed as the
three always-available core icons rather than a persisted ISAC choice.
Configure one global secondary Attribute pool above the Weapon Mod registry.
Each repeatable row contains one searchable Attribute reference and Level 1 and
Level 20 minimum and maximum fields for Specialized, Superior, and High-end.
Values between those endpoints use linear interpolation. Do not render Standard
range fields because Standard has no secondary slot. State the fixed capacity in
the pool editor and builder: Standard zero, Specialized one, Superior two, and
High-end two. The builder requires distinct selections. Individual Weapon Mod
editors must not repeat or submit pool data. A successful pool save uses the
standard shared-profile lifecycle and creates the dedicated
`game_data/weapon_mod_secondary_pool.updated` audit event.
Exotic Weapon Mod status is a separate single-icon toggle using the exotic icon
and the standard pressed, focus-visible, and selected states. Selecting it must
@@ -1970,12 +2057,20 @@ state panel visible so the change in data semantics is unmistakable.
### Gear Mod registry
Gear Mods reuse the compact quality matrix, level preview, and pooled
secondary-attribute rows established by Weapon Mods. Do not render exotic,
Gear Mods reuse the compact Level 1 and Level 20 primary-range matrix and level
preview established by regular Weapon Mods. Do not render exotic,
weapon-category, slot-type, or Mod Effect controls in this simpler editor.
Present the five primary options as an always-visible informational band because
the actual primary is selected on the build instance, not persisted on the
canonical definition.
the build instance selects the primary; the canonical definition does not store
that selection.
Configure the Gear Mod family’s global secondary Attribute pool above the
registry, separate from individual Gear Mod editors. Use the same three-quality
endpoint rows, linear interpolation, and distinct-selection rule as the Weapon
Mod pool. Capacity is zero for Standard, one for Specialized, and two each for
Superior and High-end. Individual Gear Mod edits must not repeat or submit this
pool. A successful pool save creates the dedicated
`game_data/gear_mod_secondary_pool.updated` audit event.
Use explicit textual tier capacity wherever Gear tier is summarized:
`1 MOD SLOT` for T0 and `2 MOD SLOTS` for T1/T2. On narrow screens the identity
@@ -2206,6 +2301,13 @@ active abilities, each ability has exactly three active Skill Chip definitions,
tree rows are contiguous, at least one paid node exists, and the tree uses the
configured available point total.
The ability editor may pair the open ability with one active ability from the
same specialization for Skill Chip sharing. Present this as an explicit optional
relationship, exclude the current ability, and disable abilities that already
belong to another pair. Do not infer compatibility from names or Focus
membership. Saving either side updates the shared pair and includes it in the
ability audit snapshot. Archiving either ability clears the pair.
Compose focus identity from uploaded specialization artwork and the canonical
Firepower, Toughness, or Engineering icon. Keep the specialization art primary
and place the attribute mark in a smaller corner badge. Mirror ability slots 1
@@ -2302,6 +2404,16 @@ applied to the stored value. Restoration outcomes also expose an `INSTANT` or
field and describes the configured value as the total delivered across that
duration; switching back to instant clears the duration.
Target-scoped direct-damage outcomes offer `HIT-TARGET EXPLOSION` in the Area
Targeting control. Selecting it creates an event-target-centered enemy area with
a five-meter starting radius, reveals the required radius field, and shows a
text readout for the center, valid-target policy, inclusive boundary, and
runtime evidence requirement. Only `ON EVENT` and `AFTER EVENT` remain available
for this mode; selecting it from another phase moves the outcome to
`AFTER EVENT`. Changing the outcome kind or target scope clears an incompatible
area definition. Keep `ENEMIES IN RADIUS · LEGACY` visible for saved descriptive
radius metadata, but do not present that mode as calculated explosion fan-out.
Action Family recipes are optional authoring starters. Present the recipe
catalog as a searchable assignment with only the parameters required by the
selected family. `ADD RECIPE BRANCH` copies typed branches into the current
@@ -2309,14 +2421,14 @@ source program, where authors may edit its exposed branch, state, and outcome
controls. The recipe label records provenance only. `REPLACE PROGRAM` requires
an inline confirmation that states all existing branches will be discarded.
Builder Controls define source-specific advanced scenario assumptions. Authors
choose toggle, slider, or numeric input and provide a label, Agent guidance,
default value, best-case value, and numeric bounds when applicable. Group these
controls behind the builder's advanced scenario surface. Maximum Potential uses
best-case values. Custom Scenario uses saved Agent values. A required cadence or
environmental value with no supplied assumption keeps the mechanic out of the
calculation and displays `REQUIRES ASSUMPTIONS`; the editor and runtime never
invent a cadence.
Builder Controls define source-specific scenario assumptions for the planned
player simulation. ISAC authors choose toggle, slider, or numeric input and
provide a label, Agent guidance, default value, best-case value, and numeric
bounds when applicable. The current player UI does not render these controls or
read their saved values. It uses an explicit `COMING SOON` panel for the future
advanced scenario surface. The authored contract still requires the runtime to
report `REQUIRES ASSUMPTIONS` when a required cadence or environmental value is
missing; the editor and runtime never invent a cadence.
Use `--shd-model-surface` and `--shd-model-border` for mechanic programs,
calculation revisions, and field calibration. The blue information rail marks
@@ -2482,9 +2594,40 @@ anchor scrolling and sticky-state transitions.
#### Builder calculation modes
The loadout builder places one required two-option `SegmentedChoice` above
combat assumptions. The persisted `maximum` and `custom` values use these
visible labels:
The current Builder and shared Build pages do not expose calculation modes or
combat assumptions. The Builder summary rail additionally contains one local
experimental catalog baseline panel; shared Build pages do not render it. Keep
the panel in the summary rail only, with two independent cards labeled `PRIMARY`
and `SECONDARY`.
Use the exact eyebrow `WEAPON OUTPUT / CATALOG BASELINE` and show the
`EXPERIMENTAL` state. Each card displays the catalog weapon ID, selected damage,
catalog RPM, magazine, reload, rated body-hit throughput, and magazine-cycle
body-hit throughput. Include a visible formula and exclusions block with these
exact formulas:
- `selected damage × (RPM ÷ 60)` for rated body-hit throughput;
- `(magazine × selected damage) ÷ ((magazine ÷ (RPM ÷ 60)) + reload seconds)`
for magazine-cycle body-hit throughput.
The panel is evidence-only. It excludes inferred or substituted values, combat
profiles, effects, Talents, Attribute totals, calculation profiles, accuracy,
critical hits, headshots, enemy mitigation, target count, encounter uptime,
rotations, and simulation. It must not present these values as actual, expected,
or encounter DPS. When a required source value is unavailable or invalid, show
`NOT AVAILABLE` and list the missing source fields under the metric; never
display zero as a placeholder.
The panel does not alter the planned calculation modes or full combat panels.
Keep conditional Talent outcomes, weapon and Skill DPS, encounter assumptions,
activation profiles, rotations, and simulation behind explicit `COMING SOON`
panels. Calculation modes remain planned and use these visible labels:
Loadout schema version 6 retains `maximum`, `custom`, conditional state,
encounter-assumption, and per-ability activation fields. Version 4 is obsolete
prototype data rather than a compatibility format. The current player UI ignores
the simulation fields. When player simulation ships, one required two-option
`SegmentedChoice` will use these visible labels:
- `MAXIMUM POTENTIAL` calculates a configured ceiling. It applies conditional
effects at peak state and treats selected abilities as firing on cooldown
@@ -2492,40 +2635,42 @@ visible labels:
- `CUSTOM SCENARIO` exposes encounter assumptions and source-defined advanced
controls for a player-defined scenario.
New loadouts select `MAXIMUM POTENTIAL`. The interface labels Maximum results as
a modeled ceiling so players do not read them as typical encounter performance.
Maximum hides granular assumption fields without deleting their saved values.
Returning to Custom restores those values.
The planned interface selects `MAXIMUM POTENTIAL` for new loadouts and labels
its results as a modeled ceiling. Maximum will hide granular assumption fields
without deleting their saved values. Returning to Custom will restore those
values.
Custom mode gives each unlocked conditional effect three state controls:
The planned Custom mode gives each unlocked conditional effect three state
controls:
- `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 Custom profile. Open the advanced scenario surface to show only the
toggle, slider, and numeric controls defined by that effect's owning source. The
effect card shows uptime, activation count, and applied stack state beside the
mode. A mechanic that needs an undefined cadence or another missing scenario
input displays `REQUIRES ASSUMPTIONS`; the builder withholds its calculated
contribution until the author or player supplies that input.
The planned advanced scenario surface will let users override one effect without
changing the global Custom profile. It will show only the toggle, slider, and
numeric controls defined by that effect's owning source. The effect card will
show uptime, activation count, and applied stack state beside the mode. A
mechanic that needs an undefined cadence or another missing scenario input will
display `REQUIRES ASSUMPTIONS`; the simulation will withhold its contribution
until the author or player supplies that input.
Maximum and Custom use radio semantics, an explicit selected label, keyboard
arrow navigation, and the standard orange selected rail. Results repeat the
active calculation mode in text. On mobile, the global mode control and Custom
assumption fields stack into full-width targets of at least 44 pixels. Mode
changes use the standard pressed feedback; reduced-motion preferences remove
transitions without hiding the state change.
The future Maximum and Custom control will use radio semantics, an explicit
selected label, keyboard arrow navigation, and the standard orange selected
rail. Results will repeat the active calculation mode in text. On mobile, the
global mode control and Custom assumption fields will stack into full-width
targets of at least 44 pixels. Mode changes will use the standard pressed
feedback; reduced-motion preferences will remove transitions without hiding the
state change.
Combat result panels show normal, critical, headshot, and critical-headshot
values with burst, sustained, and encounter DPS. Conditional stack telemetry
lists the mode, uptime, activations, average and peak stacks, ending stacks,
buildup time, and maintained state. A result with missing equipment, Attribute
bindings, rules, or assumptions displays `INSUFFICIENT DATA` and a concrete
remediation list. The interface withholds combat numbers until it has the
required inputs.
The future combat result panels will show normal, critical, headshot, and
critical-headshot values with burst, sustained, and encounter DPS. Conditional
stack telemetry will list the mode, uptime, activations, average and peak
stacks, ending stacks, buildup time, and maintained state. A result with missing
equipment, Attribute bindings, rules, or assumptions will display
`INSUFFICIENT DATA` and a concrete remediation list. Until this interface ships,
the player pages show `COMING SOON` instead of combat numbers.
### Specialization loadout composer
@@ -2533,24 +2678,36 @@ The builder presents Specialization and Focus as the first loadout decision. The
Focus sets the primary Attribute, progression tree, and three ordered abilities.
Its identity combines Specialization artwork and the primary Attribute mark.
Each ability panel contains three fixed Skill Chip positions. A chip picker
shows definitions assigned to that ability and excludes definitions assigned
elsewhere.
shows the ability's three definitions. When ISAC explicitly pairs that ability
with one compatible ability, the picker also shows the partner's three
definitions and labels shared entries. The relationship is bidirectional and
does not apply to other abilities in the same specialization. Players still
equip three chips in each ability panel. Skill Chips have no item-level control.
The editor uses the shared quality profiles for direct rolls. It labels the four
ability-scoped primary values `NOT YET DEFINED` and excludes them from current
Agent and combat totals. Shared-chip primary values and 3-piece Talent outcomes
target the ability represented by the panel. Secondary capacity is Standard
zero, Specialized one, Superior two, and High-end two; each equipped chip uses
distinct selections from the shared pool.
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.
Each ability panel exposes one compact activation control: Off, On Cooldown, or
Custom Rate. Custom Rate adds uses per minute. Every enabled ability has its own
target count. Skill result cards show the ability name, activation count,
single-target DPS, total-target DPS, and a combined summary. They state that
Skills can critically hit and multi-shot but cannot headshot.
Player ability panels currently expose equipped definitions and deterministic
item contributions. Planned simulation panels display `COMING SOON` instead of
activation controls or Skill DPS. The future interface will offer Off, On
Cooldown, or Custom Rate, with uses per minute and a target count for each
enabled ability. Future Skill result cards will show activation count,
single-target DPS, total-target DPS, and a combined summary. They will state
that Skills can critically hit and multi-shot but cannot headshot.
Saved loadouts store focus, node ranks, nine Skill Chips, conditional modes,
Version 5 loadouts store focus, node ranks, nine Skill Chips, conditional modes,
encounter assumptions, and per-ability activation profiles. The builder accepts
only the current complete structure. Invalid local drafts are discarded; invalid
server records are removed through a backed-up database migration.
only the current complete structure. Current player pages ignore the simulation
fields. The builder discards version 4 local drafts; a backed-up database
migration removes version 4 server records.
### Calculation revision controls
@@ -2581,6 +2738,10 @@ revision identity and before the manual stage and profile controls. Reuse the
language, identifies the active or edited base revision, and previews changes to
stages, weapon profiles, and Skill profiles.
ISAC keeps revision editing, calibration, assisted suggestions, publication, and
the calculation AI assistant available for continued model authoring. The
current Builder and shared Build pages do not consume their output.
Agents without `ai.calculation.generate` see the collapsed `PERMISSION REQUIRED`
state. Agents with the permission may request and apply a draft, but the panel
leaves every manual calculation control available. Applying a draft fills the
@@ -2611,3 +2772,13 @@ asset and states that the change is permanent. For assigned icons, the
confirmation names the owning entity type. ISAC clears that source icon
assignment before it removes the shared file. General library uploads retain the
`ICON` or `IMAGE` state.
In the Weapon Mod registry, operators manage shared artwork for all eight
code-defined slots. The compact panel labels each slot `ICON REGISTERED` or
`ICON REQUIRED`. Selecting a slot opens the standard icon upload control. Keep
the slot name visible in ISAC after an operator uploads artwork.
On public weapon cards, a configured slot chip shows only its uploaded icon.
Keep the slot name in screen-reader and hover text. Retain the visible `T1+`
marker and blue information treatment for slots unlocked at Tier 1. When artwork
is missing, show the slot name instead of an invented glyph.
+41
View File
@@ -0,0 +1,41 @@
# Weapon baseline prototype
## Scope
`calculateWeaponBaseline` is a pure, client-safe Builder helper. It records
weapon ID, name, and label, then reports three evidence-only metrics:
- `selectedDamage`: the supplied positive finite damage value.
- `ratedBodyHitThroughput`: `selectedDamage × rpm / 60`.
- `magazineCycleBodyHitThroughput`:
`(selectedDamage × magazine) / (magazine / (rpm / 60) + reloadSeconds)`.
Unknown, absent, non-positive, non-finite, or invalid source values make only
the metrics that depend on them unavailable. The helper preserves decimal inputs
and does not substitute values.
These are not actual, expected, or encounter DPS. They contain no hit
probability, critical or headshot outcomes, target state, or encounter timeline.
The rated metric is nominal continuous body-hit throughput; the magazine-cycle
metric includes the supplied reload interval.
## Excluded inputs
This helper does not read combat profiles, effects, talents, Attribute totals,
calculation profiles, encounter assumptions, or any other build inputs. It does
not call the simulation or combat engines.
`selectedDamage` is already the equipped weapon damage used at this boundary. It
is consumed exactly once; no additional level scaling or damage scaling is
applied.
## Missing data for a DPS model
A valid DPS model still needs the authoritative damage calculation and its
modifiers, hit and critical/headshot assumptions, firing and reload behavior,
target and encounter assumptions, and the timing and contribution of talents,
effects, Attributes, skills, and weapon swaps. Those data are outside this
baseline.
This is Builder-only, local prototype work. It does not define the player DPS
simulation contract or change the existing calculation architecture.
+38 -18
View File
@@ -26,8 +26,8 @@ Use these event boundaries:
- **Healing:** Count each healing event. A heal-over-time effect counts each
tick as a separate event.
- **Extra Health gained:** Count every Extra Health grant, including a grant
received while the Agent already has Extra Health. Use
`extra_health_granted` for the grant action.
received while the Agent already has Extra Health. Use `extra_health_granted`
for the grant action.
- **Extra Health state changed:** `extra_health_changed` is Agent-scoped. Emit
it when the Agent receives a grant, when the remaining amount changes, and
when it is depleted to zero. Every event requires the boolean
@@ -35,8 +35,8 @@ Use these event boundaries:
`values.currentExtraHealth`, which must agree with that status. This state
event is distinct from the `extra_health_granted` grant action.
- **Reloading:** Trigger on reload completion when the weapon had at least one
round to load. A canceled reload does not count. A weapon that loads one
round at a time produces one completed-reload event for each round.
round to load. A canceled reload does not count. A weapon that loads one round
at a time produces one completed-reload event for each round.
- **Taking damage:** Require Health loss. A hit absorbed by Extra Health does
not count as taking damage.
@@ -59,8 +59,8 @@ Inspiration and Crossfire use a binary deployed-Skill state:
- The benefit remains active while the Agent has at least one persistent
deployed Skill object.
- Destruction or timed expiration removes that object from the active count.
The benefit ends when no qualifying objects remain.
- Destruction or timed expiration removes that object from the active count. The
benefit ends when no qualifying objects remain.
- Instant-use Skills do not qualify.
- Multiple deployed Skill objects do not multiply the benefit.
@@ -75,8 +75,8 @@ meters:
- Moving within 30 meters of an ally who is already downed qualifies.
- Each qualifying presence refreshes the ten-second duration.
- Leaving the radius or reviving the ally does not cancel the current
ten-second duration.
- Leaving the radius or reviving the ally does not cancel the current ten-second
duration.
- The wearer does not count as an ally.
## Emergency Retreat
@@ -93,9 +93,9 @@ qualifies when the weapon had at least one round to load. This includes each
round inserted by a weapon that reloads one round at a time. A canceled reload
does not qualify.
Resolve the Extra Health amount from the Agent's displayed Firepower when
Second Wind activates. Include all equipped-item and active-buff contributions
in that Firepower value:
Resolve the Extra Health amount from the Agent's displayed Firepower when Second
Wind activates. Include all equipped-item and active-buff contributions in that
Firepower value:
- Tier I grants Extra Health equal to 600% of displayed Firepower.
- Tier II grants Extra Health equal to 840% of displayed Firepower.
@@ -103,19 +103,39 @@ in that Firepower value:
Second Wind adds its Extra Health to Extra Health from other sources. Its
five-second duration and five-second cooldown start when it activates. Expiry
removes only the Second Wind contribution. At the five-second boundary, the
prior grant can expire as a qualifying reload completion grants a new amount.
If Second Wind supplies the Agent's only Extra Health, the old amount expires
and the new amount begins at that boundary.
prior grant can expire as a qualifying reload completion grants a new amount. If
Second Wind supplies the Agent's only Extra Health, the old amount expires and
the new amount begins at that boundary.
## Nothing to Lose
Nothing to Lose uses a forward-looking deployment window:
- Each Health-damage instance makes its own 50% activation roll. Damage
absorbed by Extra Health does not roll.
- Each Health-damage instance makes its own 50% activation roll. Damage absorbed
by Extra Health does not roll.
- A successful activation applies to every Skill deployed during the five
seconds after that damage instance. Several Skills can qualify during the
same window.
seconds after that damage instance. Several Skills can qualify during the same
window.
- Calculate each reduction from the Skill's total cooldown after equipment
modifiers.
- A Skill can receive no more than three reductions during one cooldown cycle.
## OS Protocol Talents
Happy Survival restores Health once per second while it has a qualifying nearby
enemy count. Each restoration resolves 0.5% of maximum Health per enemy within
10 meters, capped at 2.5% before OS Protocol upgrade scaling.
Explosive Charge requires three seconds of uninterrupted firing. Completion
opens an eight-second window to deploy the next Skill. The window ends when the
Agent deploys that Skill. Only the enhanced Skill's first damaging hit creates
the five-meter explosion. The shared Talent cooldown rule starts its five-second
cooldown when that explosion activates.
Heavy Armor and Pummeling Shield replace and refresh their own active Extra
Health grant when the same Talent activates again. Extra Health grants from
different sources remain separate.
Pummeling Shield counts confirmed weapon hits while the Agent possesses Extra
Health. Losing all Extra Health resets the hit count. The full-Health condition
checks Health immediately before the incoming hit resolves.
+256 -91
View File
@@ -25,6 +25,17 @@ The team builds the project in vertical slices.
Server code checks authorization for each mutation. UI visibility has no effect
on server authorization.
Builder and shared Build pages expose deterministic totals from the equipped
items, selected rolls, fixed bonuses, inherited profiles, augments, and active
set bonuses. Builder additionally shows an experimental catalog baseline for
each selected weapon: body-hit throughput from selected weapon damage and
catalog RPM, magazine, and reload values only. It is not expected or encounter
DPS, does not infer or substitute missing values, reports blocked metrics with
their source fields, and does not change shared Build behavior. Conditional
Talent outcomes, full weapon and Skill DPS, encounter assumptions, activation
profiles, rotations, and simulation remain planned. Explicit `COMING SOON`
panels identify those player-facing capabilities.
Weapon and gear range calculations must preserve decimal endpoints and follow
the tested progression rules in
[`STAT_LEVEL_SCALING.md`](STAT_LEVEL_SCALING.md). Leveling is linear rather than
@@ -174,9 +185,45 @@ stores only its domain-separated hash. Repeated anonymous activity requires a
Turnstile result. Guide reports reference the exact published revision so a
report against an older revision cannot hide newer work.
The Builder stores loadout data only. It has no summary or Guide body. An owned
Build page links to `/guides/new?build=<id>`, where the server verifies
ownership and initializes a removable Build block in a new private Guide.
The Builder stores loadout data and displays deterministic equipped-item totals.
It has no Guide body. Shared Build pages use the same player-facing calculation
boundary. An owned Build page links to `/guides/new?build=<id>`, where the
server verifies ownership and initializes a removable Build block in a new
private Guide.
### Discord-compatible Build sharing experiment
Persisted public and unlisted Builds expose absolute Open Graph metadata for
their canonical `/builds/[slug]` URL. Their `og:image` and Twitter card metadata
reference the same-origin 1200 × 630 PNG at `/builds/[slug]/preview.png` so
Discord and other link unfurlers can render a Build summary. Private Builds set
`noindex,nofollow`, expose no crawler image, and provide no usable sharing
control.
The preview endpoint derives its image from canonical Build facts and the
current known-stat summary in the shared Build projection. It includes the Build
title, author, visibility, direct URL, equipped weapon names, core totals, and
up to three supporting Attributes. The renderer performs no external fetches. It
normalizes and bounds every text field by grapheme count, escapes text before
inserting it into XML, builds a text-only SVG, and rasterizes that SVG to PNG
with Sharp. Production installs DejaVu Sans from the Debian package and the
renderer names that fixed family explicitly.
The endpoint hashes a versioned serialization of the image input for its bounded
in-process render cache and derives the HTTP ETag from the resulting PNG bytes.
A bounded request queue limits concurrent database projections and Sharp work.
Responses require cache revalidation so a visibility change to Private blocks
the next request instead of leaving a stale response window. Matching
conditional requests receive `304 Not Modified`. Private, missing, and
inaccessible Builds receive no preview. Unlisted images send the
`X-Robots-Tag: noindex` response header.
For eligible Builds, the Build Uplink invokes the native Web Share API as the
primary action when the browser supports it. An explicit Copy Link action
remains available for every public or unlisted persisted Build. This experiment
does not install a Discord bot, post to Discord, extend Discord OAuth scopes, or
store Discord tokens or webhooks. Discord receives only a link that an Agent
chooses to share and may fetch the public metadata and same-origin preview.
## Public data corrections and discussion
@@ -388,11 +435,13 @@ reference.
# OS Protocol progression
OS Protocols use dedicated normalized tables because their talent progression
has six calculated states (base plus five upgrades) rather than the shared
two-tier talent model. Each protocol owns one `os_protocol_primary_effects`
record and one `os_protocol_talents` record. Each protocol Talent owns its
mechanic program through the shared conditional-effect tables.
OS Protocols use dedicated normalized tables because an assigned Talent has six
calculated states: its base value plus five upgrades. Each protocol owns one
`os_protocol_primary_effects` record and may own one `os_protocol_talents`
record. The optional Talent owns its mechanic program through the shared
conditional-effect tables. ISAC keeps the Talent workspace collapsed until an
operator assigns or opens it, and removing an existing Talent requires explicit
confirmation.
Dedicated profile tables normalize the three secondary attributes. One
`os_protocol_secondary_profiles` record exists for each configured quality +
@@ -417,6 +466,15 @@ compatible base weapon categories through `weapon_mod_compatible_categories`;
eligibility requires both a category match and the slot to exist on the equipped
weapon at its selected tier.
The eight code-defined slot types have stable UUIDs and one shared icon each.
Operators manage the artwork in the Weapon Mod registry through the game-data
icon pipeline. Each upload or removal appends a
`game_data/weapon_mod_slot_icon.updated` audit event, and contributors may send
the same action to Data Oversight. Public weapon cards use icon-only slot chips
when artwork exists, with the slot name available to screen readers and on
hover. Cards show the name when artwork is missing and retain the visible `T1+`
marker for slots unlocked at Tier 1.
Exotic Weapon Mods instead reference one `exotic_weapon_id`. They do not carry
regular category compatibility. The builder treats them as permanent equipment
and lets the user change only their level. They do not use quality profiles,
@@ -426,16 +484,21 @@ values in `weapon_mod_exotic_attributes`; intermediate values use linear
interpolation.
All regular Weapon Mods permit Firepower, Toughness, or Engineering as a
builder-time primary choice. `weapon_mod_quality_values` stores the shared
level-1 and level-20 primary roll endpoints for each quality; primary values use
linear interpolation from levels 1–20.
builder-time primary choice. `weapon_mod_quality_values` stores each
definition's level-1 and level-20 primary roll endpoints for each quality;
primary values use linear interpolation from levels 1–20.
`weapon_mod_secondary_options` is the shared pool from which a build selects two
distinct secondary attributes. Every option owns a minimum and maximum at each
quality through `weapon_mod_secondary_quality_values`; secondary availability is
not tied to a numbered slot in canonical data. Mod Effects are triggerless, hold
one or two attribute references, and store fixed per-quality values in
`weapon_mod_effect_quality_values`.
`weapon_mod_secondary_options` stores one global secondary Attribute pool for
all regular Weapon Mods. Each Attribute has Level 1 and Level 20 minimum and
maximum ranges for Specialized, Superior, and High-end quality through
`weapon_mod_secondary_quality_values`; values at intermediate levels use linear
interpolation. Secondary capacity is fixed by quality: Standard has zero,
Specialized has one, Superior has two, and High-end has two. A build selects
distinct Attributes from the pool up to that capacity. Individual Weapon Mod
records do not own secondary options or ranges. Pool updates append a
`game_data/weapon_mod_secondary_pool.updated` audit event. Mod Effects remain
triggerless, hold one or two Attribute references, and store fixed per-quality
values in `weapon_mod_effect_quality_values`.
### Gear Mods and tier slot capacity
@@ -445,42 +508,86 @@ Specialized, Superior, and High-end quality at Levels 1 and 20. Values between
those endpoints use linear interpolation. A build instance selects one primary
attribute from Firepower, Toughness, Engineering, Health, or Armor.
The two secondary slots draw distinct attributes from one shared option pool.
Each available secondary attribute stores its own minimum and maximum for all
four qualities. Gear tier determines capacity without per-item configuration: T0
gear has one Gear Mod slot; T1 and T2 gear have two.
One global Gear Mod secondary Attribute pool serves every Gear Mod definition.
Each Attribute has Level 1 and Level 20 minimum and maximum ranges for
Specialized, Superior, and High-end quality through
`gear_mod_secondary_quality_values`; values at intermediate levels use linear
interpolation. Secondary capacity is fixed by quality: Standard has zero,
Specialized has one, Superior has two, and High-end has two. A build selects
distinct Attributes from the pool up to that capacity. Individual Gear Mod
records do not own secondary options or ranges. Gear tier still determines mod
slot capacity without per-item configuration: T0 gear has one Gear Mod slot; T1
and T2 gear have two.
The `gear_mods`, `gear_mod_quality_values`, `gear_mod_secondary_options`, and
`gear_mod_secondary_quality_values` tables store canonical data. Successful
mutations use `gear_mod.created`, `gear_mod.updated`, `gear_mod.archived`, and
`gear_mod.unarchived` audit actions. Contributor submissions use the `gearmods`
proposal module and remain non-canonical until approved by Oversight.
The `gear_mods` and `gear_mod_quality_values` tables store individual definition
data. `gear_mod_secondary_options` and `gear_mod_secondary_quality_values` store
the family-wide pool. Pool updates append a
`game_data/gear_mod_secondary_pool.updated` audit event. Individual definition
mutations retain the `gear_mod.created`, `gear_mod.updated`,
`gear_mod.archived`, and `gear_mod.unarchived` audit actions. Contributor
submissions use the `gearmods` proposal module and remain non-canonical until
approved by Oversight.
# Skill Chip quality scaling
Skill Chip sets support Superior and High-end chip instances. Each set owns one
2-piece bonus, distinct primary and secondary stat-option pools, and one 3-piece
Talent with a typed conditional rule. Primary and secondary options reference
active Attributes that define Skill Chip minimum and maximum roll values. A
configured chip rolls one primary and two distinct secondary stats from those
pools.
Each Skill Chip definition belongs to one specialization ability. Each set owns
one 2-piece bonus and may own one 3-piece Talent with a typed conditional rule.
ISAC keeps the complete Talent workspace, including its description and mechanic
program, inside one collapsible optional section.
Each active Skill Chip definition belongs to one active specialization ability
through `skill_chip_sets.specialization_ability_id`. An ability has capacity for
three active definitions. The save and restore actions enforce that capacity,
and the schema requires the foreign key for each row. A specialization focus is
All definitions use one fixed primary profile. The profile contains Skill
Duration, Skill Radius, Skill Cooldown Recovery, and Skill Intensity, with
direct minimum and maximum values for Standard, Specialized, Superior, and
High-end. Primary values apply to the assigned ability rather than the Agent.
Builder and shared Build pages label them `NOT YET DEFINED` and exclude them
from current Agent and combat totals.
All definitions also use one shared secondary Attribute pool. Each pool entry
stores direct minimum and maximum values for Specialized, Superior, and
High-end. Standard chips have no secondary slot, Specialized chips have one, and
Superior and High-end chips have two. A configured chip selects distinct
secondary Attributes up to its quality's capacity. Skill Chips have no item
level, so neither profile uses level interpolation.
Active definitions reference an active specialization ability through
`skill_chip_sets.specialization_ability_id`. An ability has capacity for three
active definitions. The save and restore actions enforce that capacity, and the
schema requires the foreign key for each row. A specialization focus is
builder-ready only when each of its three assigned abilities has all three
active Skill Chip definitions. Skill Chip mutations refresh readiness for
focuses that reference both the previous and resulting ability.
Both set-bonus progression systems use the number of equipped High-end chips as
the interpolation step. For `h` High-end chips, where `h` is zero through three,
a configured minimum and maximum resolve as
`minimum + (maximum − minimum) × h / 3`. Zero High-end chips therefore produce
the minimum, while three produce the maximum. The editor stores endpoints and
previews all four states, preventing duplicated tier data from diverging. The
`game_data` audit stream captures Skill Chip mutations, owned relationships, and
safe icon metadata.
`specialization_ability_skill_chip_links` may pair two active abilities from the
same specialization. Operators create each pair explicitly; the application does
not infer compatibility from ability names, focus membership, or shared
specialization. The pair is bidirectional, and an ability may belong to only one
pair. This permits compatible abilities such as two Tactical Link variants to
share definitions without making unrelated pairs such as two Smart Cover
variants compatible. Archiving either ability removes the pair; restoring an
ability does not recreate it.
A linked ability can equip its own three definitions or its partner's three
definitions, so each of its three Builder positions offers at most six choices.
The loadout still equips three Skill Chips per ability and nine per focus.
Ability-scoped primary values and 3-piece Talent outcomes bind to the ability
that owns the equipped position, not the definition's original owner. Two-piece
and 3-piece thresholds remain local to one equipped ability and never combine
matching definitions across ability panels. ISAC audit snapshots and game-data
transfers include the compatibility pair.
Every equipped quality counts toward the two-piece threshold. After the loadout
meets that threshold, only equipped High-end chips increase the interpolation
step. For `h` High-end chips, where `h` is zero through three, a configured
minimum and maximum resolve as `minimum + (maximum − minimum) × h / 3`. Zero
High-end chips resolve to the minimum, while three resolve to the maximum. The
editor stores endpoints and previews all four states.
ISAC presents the fixed primary profile and secondary pool as two shared
registry/editor cards above the definition registry. Successful profile saves
append `game_data/skill_chip_primary_profile.updated` and
`game_data/skill_chip_secondary_pool.updated`, respectively. Definition
mutations continue to capture their owned bonus, optional Talent, ability
relationship, and safe icon metadata in the `game_data` audit stream.
# Mechanic programs and loadout state
@@ -490,19 +597,23 @@ Talent, OS Protocol Talent, Skill Chip Talent, or Specialization tree rank. The
`source_type` and `source_id` pair identifies that owner. No standalone Trigger
record or shared Trigger relationship participates in execution.
The builder supplies normalized timeline events to each equipped mechanic.
`movement_snapshot.values.state` is `immobile`, `moving`, or `sprinting`; the
mechanic's moving and sprinting scenario controls select that state, with
sprinting taking precedence. Enabled Skill activation profiles emit
The mechanic runtime accepts normalized timeline events for each equipped
mechanic. Current player pages do not construct this timeline or invoke the
runtime. `movement_snapshot.values.state` is `immobile`, `moving`, or
`sprinting`; the mechanic's moving and sprinting scenario controls select that
state, with sprinting taking precedence. Enabled Skill activation profiles emit
`skill_deployed` events on the same cadence as Skill calculations and identify
the deployed ability. An `on_event` Skill modifier applies to that activation
until the ability's next deployment or the encounter ends.
`skill_deployed.values.abilityId` and `skill_hit.values.abilityId` identify the
same ability for mechanics that bind an effect to the next deployed Skill.
Each source supplies a fixed value-variant contract:
- Standard Talents require Tier I and Tier II values. Exotic Talents require
Tier I and may omit Tier II until contributors document its game values.
Builder calculations use Tier I while an equipped exotic Talent has no Tier II
Runtime calculations use Tier I while an equipped exotic Talent has no Tier II
mechanic variant.
- OS Protocol Talents store a base value. An outcome may use an expression that
reads `equipment.protocolTalentUpgrades`, from zero through five, so selected
@@ -533,7 +644,23 @@ state, source, movement, and nearby counts travel as typed event values. The
event registry publishes authoritative field metadata where branches need a
bounded contract. For those events, the mechanic editor derives its event-field
controls from that metadata. For example, `weapon_hit.values.headshot` is a
boolean that distinguishes headshots from body shots.
boolean that distinguishes headshots from body shots. `enemy_killed` and
`assist` may carry `values.assignedAbilityActive`; a Skill Chip Talent uses that
boolean to restrict a kill or assist trigger to its assigned ability's active
period. `skill_deployed` and `signature_ability_deployed` may carry
`values.assignedAbilityDurationSeconds` to start assigned-ability state. The
runtime expires that state before it evaluates another event at the same
timestamp.
`weapon_hit.values.aiming` records whether the Agent was aiming down sights when
the hit resolved. A producer omits the field when it lacks authoritative aiming
state; the runtime does not replace missing evidence with a scenario assumption.
Predicates compare this field explicitly with `true` or `false`; negation and
inequality cannot turn missing evidence into a match. A `weapon_hit` or
`skill_hit` event may include an event-target-centered area sample. The bounded
sample lists up to 256 valid ally and enemy entity IDs with their distances from
the hit target and includes that target as an enemy at zero meters. Duplicate
IDs, negative distances, or a missing center target invalidate the evaluation.
`extra_health_changed` is Agent-scoped. Normalized timeline producers emit it
when the Agent receives an Extra Health grant, when the remaining amount
@@ -568,7 +695,10 @@ The program graph uses three child tables:
durations, intervals, or derived values. Mutations can set, add, consume,
clear, derive, or transfer state. A mutation may bind a target-scoped pool to
the current event target, event actor, or another typed entity selector. Each
target owns an independent value and expiry timer.
target owns an independent value and expiry timer. A clause may require active
assigned-ability state and carry `procChancePercent`. The runtime evaluates
that chance after predicates and cooldown checks, before reducers, mutations,
and outcomes.
- `conditional_effect_outcomes` applies Attribute modifiers, direct damage,
restoration, resource costs, or markers. Each outcome names its target scope,
value basis, optional resource or Attribute, combat timing, optional state
@@ -577,15 +707,37 @@ The program graph uses three child tables:
required by the owning source. The common value models are static values, live
Attribute values, percentage-normalized Attribute values, event-field values,
reducer output, source-defined scenario values, and OS Protocol upgrade
scaling. Restoration outcomes may deliver their total immediately or
scaling. An outcome may carry `probabilityPercent`; each eligible outcome
rolls separately. Restoration outcomes may deliver their total immediately or
continuously over an explicit duration.
Target-scoped direct damage may use `event_target_area` targeting for an
explosion around its event target. This targeting selects valid enemies and
stores a positive radius in meters. It is valid only for `on_event` or
`after_event` outcomes. The legacy `within_radius` shape remains descriptive
targeting metadata and does not select runtime entities.
The generic runtime in `src/lib/calculations/mechanic.ts` processes combat
events in timestamp order. Predicates decide whether an event enters a clause.
Reducers convert event streams into activations or derived values. The runtime
applies state mutations, enforces pool bounds and expiry, emits outcomes in
phase order, and records telemetry for each activation.
When `event_target_area` damage activates, the runtime requires the triggering
event's authoritative area sample. It includes valid enemies whose distance is
less than or equal to the configured radius, sorts them by entity ID, and emits
one equal damage outcome for each entity. The outcome chance resolves once
before fan-out. A missing sample invalidates the evaluation instead of producing
single-target damage or treating the area as empty.
Custom evaluation uses a Mulberry32 stream seeded by `randomSeed`; an omitted
seed resolves from the stable mechanic program ID. The returned evaluation
reports the seed. Maximum mode resolves every nonzero clause or outcome chance
as successful. `extend_duration` changes only an active assigned ability's
remaining duration. `start_cooldown_on_max` writes an expiring Agent cooldown
pool only when an earlier mutation in the same clause transitions a capped stack
pool to its maximum.
Each write to an expiring pool refreshes its timer. When a pool expires,
`on_exit` outcomes evaluate stack thresholds and per-stack expressions against
the final value before reset. The runtime records the bound target on emitted
@@ -603,11 +755,13 @@ substitute zero.
The read-only `combat.currentHealthPercent` reference divides resolved current
Health by resolved maximum Health and returns a value from 0 through 100. The
runtime derives it when a predicate is evaluated instead of storing a second
Health value that could become stale. Current timing observes deterministic
resource changes from earlier branches at the same event. Health-band mechanics
use strict upper bounds and inclusive lower bounds, so bands do not overlap and
an exact threshold enters the next weaker band. Maximum-potential evaluation
applies the same predicates before emitting passive outcomes.
Health value that could become stale. Pre-event timing reads the combat snapshot
before mechanic branches process the event. Current timing observes
deterministic resource changes from earlier branches at the same event.
Health-band mechanics use strict upper bounds and inclusive lower bounds, so
bands do not overlap and an exact threshold enters the next weaker band.
Maximum-potential evaluation applies the same predicates before emitting passive
outcomes.
Continuous restoration resolves the outcome's total when the branch activates,
then delivers that total at a constant rate over the configured duration.
@@ -622,10 +776,11 @@ uses `requires_assumptions`, identifies the missing input, and stays out of the
calculation until an operator or builder scenario supplies it.
The runtime derives a read-only pseudo-language listing from the typed program.
ISAC and the builder use that listing to explain events, predicates, reducers,
state changes, phases, and formulas. No route accepts the generated text as
input, and no server process executes it. Operators edit structured fields; the
formatter regenerates the listing after each change.
ISAC uses that listing to explain events, predicates, reducers, state changes,
phases, and formulas. Planned player calculations can reuse it when those pages
consume mechanic outcomes. No route accepts the generated text as input, and no
server process executes it. Operators edit structured fields; the formatter
regenerates the listing after each change.
## AI-assisted mechanic drafting
@@ -732,7 +887,8 @@ clarification requests. It does not grant revision mutation or publication.
Applying a result changes the open revision form in the client. The operator
must review and save that draft through the existing revision action. The
separate `calculation.publish` permission and activation confirmation remain
mandatory before the saved draft affects loadout results.
mandatory before the saved draft becomes the active authored model. Current
player pages do not consume that model.
Successful calculation-generation requests append sanitized events under
`ai_assistance`. These events use calculation-specific actions, identify the
@@ -741,23 +897,24 @@ token-usage, result, and secret-exclusion rules as mechanic-assistance events.
Applying a draft creates no server event. Existing `calculation_configuration`
events remain the evidence for revision saves and activation.
Source authors define the builder controls required by their mechanic as typed
toggle, slider, or numeric scenario inputs. Each input records its label,
guidance, bounds when applicable, default value, and best-case value. These
controls appear only in advanced scenario configuration. Maximum mode supplies
best-case values. Custom mode uses the values saved with the loadout. A mechanic
with an undefined required assumption remains excluded and reports
`REQUIRES_ASSUMPTIONS`.
Source authors define planned player scenario controls as typed toggle, slider,
or numeric inputs. Each input records its label, guidance, bounds when
applicable, default value, and best-case value. ISAC retains these definitions
for continued mechanic authoring. Current Builder and shared Build pages do not
render or evaluate them. Their calculation panels display `COMING SOON` instead.
Loadout schema version 4 stores one focus, selected tree ranks, nine Skill
Chips, a unified conditional-effect state map, encounter assumptions,
per-ability activation controls, and `calculationMode`. Runtime readers accept
the exact version 4 structure. A backed-up prototype migration removes drafts,
saved builds, and calibration snapshots that use incompatible payloads.
Loadout schema version 6 stores one focus, selected tree ranks, nine Skill
Chips, independent T1/T2 choices for each equipped gear and weapon Talent, a
unified conditional-effect state map, encounter assumptions, per-ability
activation controls, and `calculationMode`. Runtime readers accept the exact
version 6 structure. A backed-up migration translated version 5 equipment
Talents from their previous equipment-tier-derived behavior. The schema retains
its simulation fields, but the current player UI ignores them. Version 4 remains
obsolete prototype data.
## Calculation modes
## Planned player calculation modes
The builder offers `maximum` and `custom` modes.
Loadout v6 defines `maximum` and `custom` modes for the planned simulation UI:
- Maximum mode supplies each scenario input's best-case value, selects peak
conditional-effect state, and runs selected abilities on cooldown against
@@ -767,9 +924,10 @@ The builder offers `maximum` and `custom` modes.
rates, target counts, and other supported assumptions. A missing required
input produces `REQUIRES_ASSUMPTIONS` rather than a fabricated value.
Saved and shared loadouts preserve the selected mode. Calculation results label
the mode so readers can distinguish a best-case ceiling from a scenario-based
estimate.
Saved and shared loadouts preserve the selected mode as compatibility data. The
current Builder and shared Build UI does not expose a mode selector, read
encounter assumptions, or execute either mode. Explicit `COMING SOON` panels
reserve the player-facing space for these calculations.
## Specialization ability attacks
@@ -795,6 +953,12 @@ holds the first position, and the firing-cycle stage holds the last.
`attribute_calculation_profiles` maps an Attribute to a weapon stage with its
weight, optional caps, curve, and weapon-category scope.
ISAC keeps combat revision editing, publication, calibration, assisted
suggestions, and the calculation AI assistant available so operators can
continue authoring the model. Builder and shared Build pages do not load the
active revision or consume revision, calibration, assistant, mechanic-runtime,
or combat-engine output at the current player-facing boundary.
The shared combat engine consumes weapon facts, accumulated Attributes,
calculation profiles, and mechanic outcomes. It produces four hit values, burst
DPS, reload-aware sustained DPS, encounter DPS, and stack telemetry. Custom mode
@@ -803,11 +967,11 @@ configured burst size, pauses between bursts, and encounter duration. It
resolves the active Attribute state at each landed shot before calculating that
shot's expected damage.
The builder scales a weapon definition's level-1 damage range to the equipped
item level, then the agent selects one damage value inside that valid range.
Combat receives `equipped_weapon.damage` as its base damage and consumes that
number once. The combat pipeline has no `level_scaled_damage` value and must not
apply item-level scaling a second time.
The deterministic equipment path scales a weapon definition's level-1 damage
range to the equipped item level, then the agent selects one damage value inside
that valid range. A combat invocation receives `equipped_weapon.damage` as its
base damage and consumes that number once. The combat pipeline has no
`level_scaled_damage` value and must not apply item-level scaling a second time.
The generated calculation listing names each typed pipeline stage and its bound
Attributes. The listing explains the active revision with statements such as
@@ -829,8 +993,8 @@ the builder supports a combined weapon-rotation model.
The engine returns `INSUFFICIENT_DATA` with missing requirement keys when a
calculation lacks required inputs or an Attribute binding. Callers must render
that state instead of a partial result. Saved and shared builds load the current
active revision for each request.
that state instead of a partial result. Current player routes do not call the
engine; their planned calculation surfaces display `COMING SOON`.
`calculation_calibration_cases` stores a versioned loadout snapshot, observed
hit values, timing values, optional encounter DPS, and the model prediction.
@@ -872,11 +1036,12 @@ does not scale.
# Game-data icon storage
Talents, brand sets, OS Protocols, and Skill Chips reference uploaded artwork
through the polymorphic `game_data_icons` metadata table. The shared server
pipeline accepts transparent SVG, PNG, WebP, and AVIF sources, rejects active
SVG content and external references, and uses Sharp’s native worker pool to
rasterize and normalize each accepted icon to a transparent 256 × 256 WebP.
Talents, brand sets, OS Protocols, Skill Chips, Specializations, and shared
Weapon Mod slots reference uploaded artwork through the polymorphic
`game_data_icons` metadata table. The shared server pipeline accepts transparent
SVG, PNG, WebP, and AVIF sources, rejects active SVG content and external
references, and uses Sharp’s native worker pool to rasterize and normalize each
accepted icon to a transparent 256 × 256 WebP.
Normalized files live beneath the ignored `data/game-icons/` directory. The
validated `/icons/game-data/[type]/[id]` endpoint serves them with immutable
+555 -7
View File
@@ -45,7 +45,8 @@ const isRoll = (value) =>
isFiniteNumber(value.value);
const isRollPair = (value) =>
Array.isArray(value) && value.length === 2 && value.every(isRoll);
const isGearSelection = (value) =>
const isTalentTier = (value) => value === 1 || value === 2;
const isLegacyGearSelection = (value) =>
hasExactKeys(value, [
'itemId',
'level',
@@ -68,7 +69,7 @@ const isGearSelection = (value) =>
isRollPair(value.rolls) &&
Array.isArray(value.augmentSlots) &&
value.augmentSlots.every(isFiniteNumber);
const isWeaponSelection = (value) =>
const isLegacyWeaponSelection = (value) =>
hasExactKeys(value, [
'itemId',
'level',
@@ -88,12 +89,68 @@ const isWeaponSelection = (value) =>
(key) => isFiniteNumber(value[key]),
) &&
isRollPair(value.rolls);
const isGearSelection = (value) =>
hasExactKeys(value, [
'itemId',
'level',
'brandId',
'talentId',
'talentTier',
'health',
'armor',
'firepower',
'toughness',
'engineering',
'rolls',
'augmentSlots',
]) &&
['itemId', 'brandId', 'talentId'].every(
(key) => typeof value[key] === 'string',
) &&
isTalentTier(value.talentTier) &&
['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every(
(key) => isFiniteNumber(value[key]),
) &&
isRollPair(value.rolls) &&
Array.isArray(value.augmentSlots) &&
value.augmentSlots.every(isFiniteNumber);
const isWeaponSelection = (value) =>
hasExactKeys(value, [
'itemId',
'level',
'tier',
'damage',
'firepower',
'toughness',
'engineering',
'talent1Id',
'talent1Tier',
'talent2Id',
'talent2Tier',
'exoticTalentTier',
'rolls',
]) &&
['itemId', 'talent1Id', 'talent2Id'].every(
(key) => typeof value[key] === 'string',
) &&
isTalentTier(value.talent1Tier) &&
isTalentTier(value.talent2Tier) &&
isTalentTier(value.exoticTalentTier) &&
['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every(
(key) => isFiniteNumber(value[key]),
) &&
isRollPair(value.rolls);
const isChipSelection = (value) =>
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
typeof value.setId === 'string' &&
(value.quality === 'Superior' || value.quality === 'High-end') &&
['Standard', 'Specialized', 'Superior', 'High-end'].includes(value.quality) &&
isRoll(value.primary) &&
isRollPair(value.secondary);
Array.isArray(value.secondary) &&
value.secondary.length ===
({ Standard: 0, Specialized: 1, Superior: 2, 'High-end': 2 }[
value.quality
] ?? -1) &&
value.secondary.every(isRoll);
const isProtocolSelection = (value) =>
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) &&
typeof value.itemId === 'string' &&
@@ -185,7 +242,7 @@ const isCurrentLoadoutSnapshot = (raw) => {
'skillActivations',
'specialization',
]) &&
value.version === 4 &&
value.version === 6 &&
typeof value.buildName === 'string' &&
['maximum', 'custom'].includes(value.calculationMode) &&
hasExactKeys(value.gear, gearSlots) &&
@@ -207,6 +264,49 @@ const isCurrentLoadoutSnapshot = (raw) => {
);
};
const isLegacyLoadoutSnapshot = (raw) => {
let value;
try {
value = typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch {
return false;
}
return (
hasExactKeys(value, [
'version',
'buildName',
'gear',
'weapons',
'chips',
'protocol',
'conditionalEffects',
'combatProfile',
'calculationMode',
'skillActivations',
'specialization',
]) &&
value.version === 5 &&
typeof value.buildName === 'string' &&
['maximum', 'custom'].includes(value.calculationMode) &&
hasExactKeys(value.gear, gearSlots) &&
gearSlots.every((slot) => isLegacyGearSelection(value.gear[slot])) &&
Array.isArray(value.weapons) &&
value.weapons.length === 2 &&
value.weapons.every(isLegacyWeaponSelection) &&
Array.isArray(value.chips) &&
value.chips.length === 9 &&
value.chips.every(isChipSelection) &&
isProtocolSelection(value.protocol) &&
isEffectStates(value.conditionalEffects) &&
isCombatProfile(value.combatProfile) &&
isSkillActivations(value.skillActivations) &&
hasExactKeys(value.specialization, ['focusId', 'nodeRanks']) &&
typeof value.specialization.focusId === 'string' &&
isRecord(value.specialization.nodeRanks) &&
Object.values(value.specialization.nodeRanks).every(isFiniteNumber)
);
};
const databasePath = resolve(
process.env.TDR_DATABASE_PATH || './data/shd-build.db',
);
@@ -669,6 +769,44 @@ for (const statement of statements) {
await database.exec(statement);
}
await database.exec(`CREATE TRIGGER IF NOT EXISTS specialization_ability_skill_chip_links_validate_insert
BEFORE INSERT ON specialization_ability_skill_chip_links
BEGIN
SELECT CASE WHEN EXISTS (
SELECT 1 FROM specialization_ability_skill_chip_links link
WHERE link.ability_id IN (NEW.ability_id,NEW.compatible_ability_id)
OR link.compatible_ability_id IN (NEW.ability_id,NEW.compatible_ability_id)
) THEN RAISE(ABORT,'Each ability can share one Skill Chip pool') END;
SELECT CASE WHEN NOT EXISTS (
SELECT 1 FROM specialization_abilities ability
JOIN specialization_abilities compatible
ON compatible.id=NEW.compatible_ability_id
WHERE ability.id=NEW.ability_id
AND ability.active=1
AND compatible.active=1
AND ability.specialization_id=compatible.specialization_id
) THEN RAISE(ABORT,'Skill Chip sharing requires active abilities from one specialization') END;
END`);
await database.exec(`CREATE TRIGGER IF NOT EXISTS specialization_ability_skill_chip_links_validate_update
BEFORE UPDATE ON specialization_ability_skill_chip_links
BEGIN
SELECT CASE WHEN EXISTS (
SELECT 1 FROM specialization_ability_skill_chip_links link
WHERE link.rowid<>OLD.rowid
AND (link.ability_id IN (NEW.ability_id,NEW.compatible_ability_id)
OR link.compatible_ability_id IN (NEW.ability_id,NEW.compatible_ability_id))
) THEN RAISE(ABORT,'Each ability can share one Skill Chip pool') END;
SELECT CASE WHEN NOT EXISTS (
SELECT 1 FROM specialization_abilities ability
JOIN specialization_abilities compatible
ON compatible.id=NEW.compatible_ability_id
WHERE ability.id=NEW.ability_id
AND ability.active=1
AND compatible.active=1
AND ability.specialization_id=compatible.specialization_id
) THEN RAISE(ABORT,'Skill Chip sharing requires active abilities from one specialization') END;
END`);
const articleRevisionColumns = await (
await database.prepare("PRAGMA table_info('article_revisions')")
).all();
@@ -2392,6 +2530,32 @@ if (
);
}
const weaponModSlotIconDefinition = await (
await database.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'",
)
).get();
if (
typeof weaponModSlotIconDefinition?.sql === 'string' &&
!weaponModSlotIconDefinition.sql.includes("'weapon_mod_slot'")
) {
console.log('Adding shared Weapon Mod slot icon storage support.');
await database.exec(`CREATE TABLE game_data_icons_weapon_mod_slots (
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node', 'weapon_mod_slot')),
entity_id TEXT NOT NULL,
storage_key TEXT NOT NULL UNIQUE,
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
updated_at TEXT NOT NULL,
PRIMARY KEY(entity_type, entity_id)
)`);
await database.exec(`INSERT INTO game_data_icons_weapon_mod_slots
SELECT entity_type,entity_id,storage_key,byte_size,updated_at FROM game_data_icons`);
await database.exec('DROP TABLE game_data_icons');
await database.exec(
'ALTER TABLE game_data_icons_weapon_mod_slots RENAME TO game_data_icons',
);
}
const managedImageColumns = await (
await database.prepare("PRAGMA table_info('managed_images')")
).all();
@@ -2433,6 +2597,7 @@ const iconLibraryCategory = {
specialization: 'Specializations',
specialization_ability: 'Specializations',
specialization_node: 'Specializations',
weapon_mod_slot: 'Weapon Mods',
};
const iconLibraryLabel = {
talent: 'Talent',
@@ -2442,6 +2607,17 @@ const iconLibraryLabel = {
specialization: 'Specialization',
specialization_ability: 'Specialization Ability',
specialization_node: 'Specialization Tree Node',
weapon_mod_slot: 'Weapon Mod Slot',
};
const weaponModSlotNames = {
'28ffc3b1-5d98-4273-a9af-67dbff3e5c76': 'Optics',
'c4598f51-2cb0-4a20-a3a7-6d8983a26598': 'Muzzle',
'85ca3493-1871-4d22-950a-ed718fd547e0': 'Underbarrel',
'4d809a35-8285-4bff-be98-a0effe1d257e': 'Magazine',
'0e157cfe-975c-441d-8f49-72983161e7b1': 'Barrel',
'551fd7c8-3e5b-4c89-8437-9b901e89e8aa': 'Upper Receiver',
'72573367-6e91-4312-9cc3-710ede4f078e': 'Lower Receiver',
'8dda5edb-6079-4a23-a556-cd9be4521c70': 'Trigger',
};
const normalizeLibraryTags = (values) =>
[
@@ -2532,6 +2708,13 @@ const linkedIconMetadata = async (entityType, entityId) => {
],
};
}
case 'weapon_mod_slot': {
const name = weaponModSlotNames[entityId];
return {
name: name ? `${name} Slot` : null,
tags: ['weapon mod', 'slot', name],
};
}
default:
return { name: null, tags: [] };
}
@@ -2912,6 +3095,302 @@ if (
await database.exec('PRAGMA foreign_keys=ON');
}
for (const kind of ['weapon', 'gear']) {
const optionTable = `${kind}_mod_secondary_options`;
const valueTable = `${kind}_mod_secondary_quality_values`;
const optionColumns = await (
await database.prepare(`PRAGMA table_info('${optionTable}')`)
).all();
if (!optionColumns.some((column) => column.name === `${kind}_mod_id`))
continue;
const conflictingRanges = await (
await database.prepare(`SELECT stat_attribute_id,quality,
COUNT(DISTINCT CAST(min_value AS TEXT)||'/'||CAST(max_value AS TEXT)) AS variants
FROM ${valueTable}
WHERE quality IN ('Specialized','Superior','High-end')
GROUP BY stat_attribute_id,quality HAVING variants>1`)
).all();
if (conflictingRanges.length) {
throw new Error(
`Cannot create the shared ${kind} Mod secondary pool because existing per-Mod ranges disagree. Reconcile ${conflictingRanges.map((row) => `${row.stat_attribute_id}/${row.quality}`).join(', ')} first.`,
);
}
const incompleteOptions = await (
await database.prepare(`SELECT options.stat_attribute_id,
COUNT(DISTINCT CASE WHEN values_row.quality IN ('Specialized','Superior','High-end') THEN values_row.quality END) AS qualities
FROM ${optionTable} options
LEFT JOIN ${valueTable} values_row ON values_row.${kind}_mod_id=options.${kind}_mod_id
AND values_row.stat_attribute_id=options.stat_attribute_id
GROUP BY options.${kind}_mod_id,options.stat_attribute_id HAVING qualities<>3`)
).all();
if (incompleteOptions.length) {
throw new Error(
`Cannot create the shared ${kind} Mod secondary pool because ${incompleteOptions.map((row) => row.stat_attribute_id).join(', ')} does not have all applicable quality ranges.`,
);
}
console.log(
`Converting ${kind} Mod secondary Attributes to one shared level-scaled pool.`,
);
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
await database.exec(`CREATE TABLE ${optionTable}_shared (
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
)`);
await database.exec(`CREATE TABLE ${valueTable}_shared (
stat_attribute_id TEXT NOT NULL,
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
level_1_min REAL NOT NULL,
level_1_max REAL NOT NULL,
level_20_min REAL NOT NULL,
level_20_max REAL NOT NULL,
PRIMARY KEY(stat_attribute_id,quality),
FOREIGN KEY(stat_attribute_id) REFERENCES ${optionTable}_shared(stat_attribute_id) ON DELETE CASCADE,
CHECK(level_1_max>=level_1_min),
CHECK(level_20_max>=level_20_min),
CHECK(level_20_min>=level_1_min),
CHECK(level_20_max>=level_1_max)
)`);
await database.exec(`INSERT INTO ${optionTable}_shared(stat_attribute_id)
SELECT DISTINCT stat_attribute_id FROM ${optionTable}`);
await database.exec(`INSERT INTO ${valueTable}_shared(
stat_attribute_id,quality,level_1_min,level_1_max,level_20_min,level_20_max
) SELECT stat_attribute_id,quality,MIN(min_value),MAX(max_value),MIN(min_value),MAX(max_value)
FROM ${valueTable}
WHERE quality IN ('Specialized','Superior','High-end')
GROUP BY stat_attribute_id,quality`);
await database.exec(`DROP TABLE ${valueTable}`);
await database.exec(`DROP TABLE ${optionTable}`);
await database.exec(
`ALTER TABLE ${optionTable}_shared RENAME TO ${optionTable}`,
);
await database.exec(
`ALTER TABLE ${valueTable}_shared RENAME TO ${valueTable}`,
);
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
}
const skillChipPrimaryOptionColumns = await (
await database.prepare("PRAGMA table_info('skill_chip_primary_options')")
).all();
if (
skillChipPrimaryOptionColumns.some(
(column) => column.name === 'skill_chip_set_id',
)
) {
const fixedSkillChipAttributes = await (
await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active
FROM stat_attributes
WHERE name IN ('Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity')`)
).get();
if (
Number(fixedSkillChipAttributes?.total) !== 4 ||
Number(fixedSkillChipAttributes?.active) !== 4
)
throw new Error(
'Skill Chip profile migration requires exactly four active fixed primary Attributes.',
);
console.log(
'Converting Skill Chip primary and secondary options to shared quality profiles.',
);
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
await database.exec(
'DROP TABLE IF EXISTS skill_chip_primary_quality_values',
);
await database.exec(
'DROP TABLE IF EXISTS skill_chip_secondary_quality_values',
);
await database.exec(`CREATE TABLE skill_chip_primary_options_shared (
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
)`);
await database.exec(`CREATE TABLE skill_chip_primary_quality_values_shared (
stat_attribute_id TEXT NOT NULL,
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
min_value REAL NOT NULL,
max_value REAL NOT NULL,
PRIMARY KEY(stat_attribute_id,quality),
FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_primary_options_shared(stat_attribute_id) ON DELETE CASCADE,
CHECK(max_value>=min_value),
CHECK(min_value>=0)
)`);
await database.exec(`CREATE TABLE skill_chip_secondary_options_shared (
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
)`);
await database.exec(`CREATE TABLE skill_chip_secondary_quality_values_shared (
stat_attribute_id TEXT NOT NULL,
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
min_value REAL NOT NULL,
max_value REAL NOT NULL,
PRIMARY KEY(stat_attribute_id,quality),
FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_secondary_options_shared(stat_attribute_id) ON DELETE CASCADE,
CHECK(max_value>=min_value),
CHECK(min_value>=0)
)`);
await database.exec(`INSERT INTO skill_chip_primary_options_shared(stat_attribute_id)
SELECT id FROM stat_attributes
WHERE name IN ('Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity')
AND active=1`);
await database.exec(`INSERT INTO skill_chip_primary_quality_values_shared(
stat_attribute_id,quality,min_value,max_value
) SELECT attributes.id,qualities.quality,attributes.skill_chip_min,attributes.skill_chip_max
FROM stat_attributes attributes
CROSS JOIN (
SELECT 'Standard' quality UNION ALL SELECT 'Specialized'
UNION ALL SELECT 'Superior' UNION ALL SELECT 'High-end'
) qualities
WHERE attributes.name IN ('Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity')
AND attributes.active=1
AND attributes.skill_chip_min IS NOT NULL
AND attributes.skill_chip_max IS NOT NULL`);
await database.exec(`INSERT INTO skill_chip_secondary_options_shared(stat_attribute_id)
SELECT DISTINCT stat_attribute_id FROM skill_chip_secondary_options`);
await database.exec(`INSERT INTO skill_chip_secondary_quality_values_shared(
stat_attribute_id,quality,min_value,max_value
) SELECT options.stat_attribute_id,qualities.quality,attributes.skill_chip_min,attributes.skill_chip_max
FROM (SELECT DISTINCT stat_attribute_id FROM skill_chip_secondary_options) options
JOIN stat_attributes attributes ON attributes.id=options.stat_attribute_id
CROSS JOIN (
SELECT 'Specialized' quality UNION ALL SELECT 'Superior' UNION ALL SELECT 'High-end'
) qualities
WHERE attributes.skill_chip_min IS NOT NULL
AND attributes.skill_chip_max IS NOT NULL`);
await database.exec('DROP TABLE skill_chip_primary_options');
await database.exec('DROP TABLE skill_chip_secondary_options');
await database.exec(
'ALTER TABLE skill_chip_primary_options_shared RENAME TO skill_chip_primary_options',
);
await database.exec(
'ALTER TABLE skill_chip_primary_quality_values_shared RENAME TO skill_chip_primary_quality_values',
);
await database.exec(
'ALTER TABLE skill_chip_secondary_options_shared RENAME TO skill_chip_secondary_options',
);
await database.exec(
'ALTER TABLE skill_chip_secondary_quality_values_shared RENAME TO skill_chip_secondary_quality_values',
);
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
}
await database.exec(`INSERT OR IGNORE INTO skill_chip_primary_options(stat_attribute_id)
SELECT id FROM stat_attributes
WHERE active=1 AND name IN (
'Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity'
)`);
const skillChipRangeAttributeColumns = await (
await database.prepare("PRAGMA table_info('stat_attributes')")
).all();
if (
skillChipRangeAttributeColumns.some(
(column) => column.name === 'skill_chip_min',
)
) {
console.log(
'Removing obsolete Attribute-level Skill Chip ranges in favor of shared quality profiles.',
);
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
await database.exec(
'DROP INDEX IF EXISTS stat_attributes_name_unit_nocase_idx',
);
await database.exec(`CREATE TABLE stat_attributes_current (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL CHECK(type IN ('Offensive','Defensive')),
category TEXT NOT NULL CHECK(category IN ('Weapon','Skill','Character')),
calculation_type TEXT NOT NULL CHECK(calculation_type IN ('Additive','Multiplicative')),
unit TEXT NOT NULL DEFAULT '',
gear_min REAL,
gear_max REAL,
gear_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(gear_reference_level BETWEEN 1 AND 50),
weapon_min REAL,
weapon_max REAL,
weapon_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50),
cap_value REAL,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK((gear_min IS NULL AND gear_max IS NULL) OR (gear_min IS NOT NULL AND gear_max IS NOT NULL AND gear_min<=gear_max)),
CHECK((weapon_min IS NULL AND weapon_max IS NULL) OR (weapon_min IS NOT NULL AND weapon_max IS NOT NULL AND weapon_min<=weapon_max)),
CHECK(cap_value IS NULL OR cap_value>=0)
)`);
await database.exec(`INSERT INTO stat_attributes_current(
id,name,description,type,category,calculation_type,unit,
gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level,
cap_value,active,created_at,updated_at
) SELECT id,name,description,type,category,calculation_type,unit,
gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level,
cap_value,active,created_at,updated_at FROM stat_attributes`);
await database.exec('DROP TABLE stat_attributes');
await database.exec(
'ALTER TABLE stat_attributes_current RENAME TO stat_attributes',
);
await database.exec(
'CREATE UNIQUE INDEX stat_attributes_name_unit_nocase_idx ON stat_attributes(name COLLATE NOCASE,unit)',
);
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
}
const [loadoutGearRows, loadoutWeaponRows, loadoutTalentTierRows] =
await Promise.all([
(await database.prepare('SELECT id,tier FROM gear')).all(),
(await database.prepare('SELECT id,exotic_talent_id FROM weapons')).all(),
(await database.prepare('SELECT talent_id,tier FROM talent_tiers')).all(),
]);
const migrateLegacyLoadoutSnapshot = (raw) => {
if (!isLegacyLoadoutSnapshot(raw)) return null;
const value =
typeof raw === 'string' ? JSON.parse(raw) : structuredClone(raw);
for (const slot of gearSlots) {
const item = loadoutGearRows.find(
(entry) => String(entry.id) === String(value.gear[slot].itemId),
);
value.gear[slot].talentTier = Number(item?.tier) >= 2 ? 2 : 1;
}
for (const selection of value.weapons) {
const inheritedTier = Number(selection.tier) >= 2 ? 2 : 1;
selection.talent1Tier = inheritedTier;
selection.talent2Tier = inheritedTier;
const weapon = loadoutWeaponRows.find(
(entry) => String(entry.id) === String(selection.itemId),
);
const exoticTalentId = String(weapon?.exotic_talent_id ?? '');
const hasExoticTierTwo = loadoutTalentTierRows.some(
(entry) =>
String(entry.talent_id) === exoticTalentId && entry.tier === 'II',
);
selection.exoticTalentTier =
inheritedTier === 2 && hasExoticTierTwo ? 2 : 1;
}
value.version = 6;
return isCurrentLoadoutSnapshot(value) ? JSON.stringify(value) : null;
};
const buildColumns = await (
await database.prepare("PRAGMA table_info('builds')")
).all();
@@ -2926,9 +3405,34 @@ if (buildColumns.length) {
await database.exec(
"UPDATE builds SET visibility='private' WHERE visibility='draft'",
);
const builds = await (
let builds = await (
await database.prepare('SELECT id,loadout_json FROM builds')
).all();
const migratedBuilds = builds
.map((build) => ({
id: String(build.id),
loadout: migrateLegacyLoadoutSnapshot(build.loadout_json),
}))
.filter((build) => build.loadout);
if (migratedBuilds.length) {
console.log(
`Migrating ${migratedBuilds.length} build loadout snapshot(s) to version 6 Talent tiers.`,
);
await database.exec('BEGIN');
try {
for (const build of migratedBuilds)
await (
await database.prepare('UPDATE builds SET loadout_json=? WHERE id=?')
).run(build.loadout, build.id);
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
builds = await (
await database.prepare('SELECT id,loadout_json FROM builds')
).all();
}
const obsoleteBuildIds = builds
.filter((build) => !isCurrentLoadoutSnapshot(build.loadout_json))
.map((build) => String(build.id));
@@ -3305,11 +3809,55 @@ if (
);
}
const calibrationCases = await (
const outcomeProbabilityColumns = await (
await database.prepare("PRAGMA table_info('conditional_effect_outcomes')")
).all();
if (
!outcomeProbabilityColumns.some(
(column) => column.name === 'probability_percent',
)
) {
await database.exec(
'ALTER TABLE conditional_effect_outcomes ADD COLUMN probability_percent REAL CHECK(probability_percent IS NULL OR (probability_percent>=0 AND probability_percent<=100))',
);
}
let calibrationCases = await (
await database.prepare(
'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases',
)
).all();
const migratedCalibrationCases = calibrationCases
.map((calibrationCase) => ({
id: String(calibrationCase.id),
loadout: migrateLegacyLoadoutSnapshot(
calibrationCase.loadout_snapshot_json,
),
}))
.filter((calibrationCase) => calibrationCase.loadout);
if (migratedCalibrationCases.length) {
console.log(
`Migrating ${migratedCalibrationCases.length} calibration snapshot(s) to version 6 Talent tiers.`,
);
await database.exec('BEGIN');
try {
for (const calibrationCase of migratedCalibrationCases)
await (
await database.prepare(
'UPDATE calculation_calibration_cases SET loadout_snapshot_json=? WHERE id=?',
)
).run(calibrationCase.loadout, calibrationCase.id);
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
calibrationCases = await (
await database.prepare(
'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases',
)
).all();
}
const obsoleteCalibrationIds = calibrationCases
.filter(
(calibrationCase) =>
+72 -2
View File
@@ -44,21 +44,91 @@ for (const table of backup.tables) {
}
let restoredRows = 0;
const attributeTable = backup.tables.find(
(table) => table.name === 'stat_attributes',
);
const legacySkillChipProfiles = attributeTable?.rows.some(
(row) => 'skill_chip_min' in row || 'skill_chip_max' in row,
);
const legacySkillChipRanges = new Map(
(attributeTable?.rows ?? []).map((row) => [
String(row.id),
{ min: row.skill_chip_min, max: row.skill_chip_max },
]),
);
const restoredLegacySecondaries = new Set();
await database.exec('BEGIN');
try {
for (const table of backup.tables) {
for (const row of table.rows) {
const columns = Object.keys(row);
if (
legacySkillChipProfiles &&
table.name === 'skill_chip_primary_options'
)
continue;
const restoredRow = { ...row };
if (legacySkillChipProfiles && table.name === 'stat_attributes') {
delete restoredRow.skill_chip_min;
delete restoredRow.skill_chip_max;
}
if (
legacySkillChipProfiles &&
table.name === 'skill_chip_secondary_options'
) {
delete restoredRow.skill_chip_set_id;
const statId = String(restoredRow.stat_attribute_id ?? '');
if (restoredLegacySecondaries.has(statId)) continue;
restoredLegacySecondaries.add(statId);
}
const columns = Object.keys(restoredRow);
if (!columns.length) continue;
const columnSql = columns.map(quoteIdentifier).join(', ');
const placeholders = columns.map(() => '?').join(', ');
const insert = await database.prepare(
`INSERT INTO ${quoteIdentifier(table.name)} (${columnSql}) VALUES (${placeholders})`,
);
await insert.run(...columns.map((column) => row[column]));
await insert.run(...columns.map((column) => restoredRow[column]));
restoredRows++;
}
}
if (legacySkillChipProfiles) {
const primaryAttributes = await (
await database.prepare(`SELECT id,active FROM stat_attributes WHERE name IN (
'Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity'
)`)
).all();
if (
primaryAttributes.length !== 4 ||
primaryAttributes.some((attribute) => Number(attribute.active) !== 1)
)
throw new Error(
'Legacy restore requires exactly four active fixed Skill Chip primary Attributes.',
);
for (const attribute of primaryAttributes) {
const statId = String(attribute.id);
await (
await database.prepare(
'INSERT INTO skill_chip_primary_options(stat_attribute_id) VALUES(?)',
)
).run(statId);
const range = legacySkillChipRanges.get(statId);
if (!range || range.min === null || range.max === null) continue;
for (const quality of ['Standard', 'Specialized', 'Superior', 'High-end'])
await (
await database.prepare(`INSERT INTO skill_chip_primary_quality_values
(stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?)`)
).run(statId, quality, range.min, range.max);
}
for (const statId of restoredLegacySecondaries) {
const range = legacySkillChipRanges.get(statId);
if (!range || range.min === null || range.max === null) continue;
for (const quality of ['Specialized', 'Superior', 'High-end'])
await (
await database.prepare(`INSERT INTO skill_chip_secondary_quality_values
(stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?)`)
).run(statId, quality, range.min, range.max);
}
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
+3 -9
View File
@@ -98,8 +98,6 @@ try {
weapon_min: record.weaponRange?.[0] ?? existing?.weapon_min ?? null,
weapon_max: record.weaponRange?.[1] ?? existing?.weapon_max ?? null,
weapon_reference_level: Number(existing?.weapon_reference_level ?? 50),
skill_chip_min: existing?.skill_chip_min ?? null,
skill_chip_max: existing?.skill_chip_max ?? null,
cap_value: existing?.cap_value ?? null,
active: 1,
created_at: String(existing?.created_at ?? now),
@@ -117,7 +115,7 @@ try {
await (
await database.prepare(`UPDATE stat_attributes SET
name=?,description=?,type=?,category=?,calculation_type=?,unit=?,
gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,
gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,
cap_value=?,active=1,updated_at=? WHERE id=?`)
).run(
next.name,
@@ -130,8 +128,6 @@ try {
next.gear_max,
next.weapon_min,
next.weapon_max,
next.skill_chip_min,
next.skill_chip_max,
next.cap_value,
now,
id,
@@ -142,8 +138,8 @@ try {
await database.prepare(`INSERT INTO stat_attributes(
id,name,description,type,category,calculation_type,unit,
gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level,
skill_chip_min,skill_chip_max,cap_value,active,created_at,updated_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`)
cap_value,active,created_at,updated_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`)
).run(
id,
next.name,
@@ -158,8 +154,6 @@ try {
next.weapon_min,
next.weapon_max,
next.weapon_reference_level,
next.skill_chip_min,
next.skill_chip_max,
next.cap_value,
now,
now,
+6 -2
View File
@@ -2,7 +2,11 @@ import { connect } from '@tursodatabase/database';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, dirname, join, resolve } from 'node:path';
import process from 'node:process';
import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs';
import {
ISAC_DATA_FORMAT_VERSION,
ISAC_DATA_TABLES,
quoteIdentifier,
} from './isac-data-tables.mjs';
const databasePath = resolve(
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
@@ -67,7 +71,7 @@ for (const row of [
const snapshot = {
format: 'shd-build-isac-data',
version: 2,
version: ISAC_DATA_FORMAT_VERSION,
createdAt: new Date().toISOString(),
sourceDatabase: basename(databasePath),
totalRows,
+233 -2
View File
@@ -3,7 +3,11 @@ import { spawnSync } from 'node:child_process';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, join, resolve } from 'node:path';
import process from 'node:process';
import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs';
import {
ISAC_DATA_FORMAT_VERSION,
ISAC_DATA_TABLES,
quoteIdentifier,
} from './isac-data-tables.mjs';
const snapshotArgument = process.argv
.slice(2)
@@ -22,7 +26,7 @@ const snapshotPath = resolve(snapshotArgument);
const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8'));
if (
snapshot?.format !== 'shd-build-isac-data' ||
snapshot?.version !== 2 ||
snapshot?.version !== ISAC_DATA_FORMAT_VERSION ||
!Array.isArray(snapshot.tables) ||
!Array.isArray(snapshot.icons)
) {
@@ -39,6 +43,233 @@ if (
'The ISAC snapshot table manifest is incomplete or out of order.',
);
}
const abilityRows = snapshot.tables.find(
(table) => table.name === 'specialization_abilities',
).rows;
const abilitiesById = new Map(
abilityRows.map((row) => [String(row.id ?? ''), row]),
);
const skillChipRows = snapshot.tables.find(
(table) => table.name === 'skill_chip_sets',
).rows;
const activeSkillChipCounts = new Map();
for (const row of skillChipRows) {
const abilityId = String(row.specialization_ability_id ?? '');
if (!abilityId || !abilitiesById.has(abilityId)) {
throw new Error(
`The ISAC snapshot contains a Skill Chip definition with a missing ability reference "${abilityId}".`,
);
}
if (Number(row.active) !== 1) continue;
const count = (activeSkillChipCounts.get(abilityId) ?? 0) + 1;
if (count > 3) {
throw new Error(
`The ISAC snapshot assigns more than three active Skill Chip definitions to ability "${abilityId}".`,
);
}
activeSkillChipCounts.set(abilityId, count);
}
const skillChipLinks = snapshot.tables.find(
(table) => table.name === 'specialization_ability_skill_chip_links',
).rows;
const linkedAbilityIds = new Set();
for (const row of skillChipLinks) {
const abilityId = row.ability_id;
const compatibleAbilityId = row.compatible_ability_id;
if (
typeof abilityId !== 'string' ||
!abilityId ||
typeof compatibleAbilityId !== 'string' ||
!compatibleAbilityId
) {
throw new Error(
'The ISAC snapshot contains a Skill Chip sharing link with malformed ability references.',
);
}
if (abilityId === compatibleAbilityId) {
throw new Error(
`The ISAC snapshot contains a self-referencing Skill Chip sharing link for ability "${abilityId}".`,
);
}
if (abilityId >= compatibleAbilityId) {
throw new Error(
`The ISAC snapshot contains a Skill Chip sharing link that is not in canonical ability order: "${abilityId}", "${compatibleAbilityId}".`,
);
}
const ability = abilitiesById.get(abilityId);
const compatibleAbility = abilitiesById.get(compatibleAbilityId);
if (!ability || !compatibleAbility) {
throw new Error(
`The ISAC snapshot contains a Skill Chip sharing link with a missing ability reference: "${abilityId}", "${compatibleAbilityId}".`,
);
}
if (Number(ability.active) !== 1 || Number(compatibleAbility.active) !== 1) {
throw new Error(
`The ISAC snapshot contains a Skill Chip sharing link with an inactive ability: "${abilityId}", "${compatibleAbilityId}".`,
);
}
if (ability.specialization_id !== compatibleAbility.specialization_id) {
throw new Error(
`The ISAC snapshot contains a cross-specialization Skill Chip sharing link: "${abilityId}", "${compatibleAbilityId}".`,
);
}
if (
linkedAbilityIds.has(abilityId) ||
linkedAbilityIds.has(compatibleAbilityId)
) {
throw new Error(
'The ISAC snapshot contains more than one Skill Chip sharing link touching an ability.',
);
}
linkedAbilityIds.add(abilityId);
linkedAbilityIds.add(compatibleAbilityId);
}
for (const kind of ['weapon', 'gear']) {
const options = snapshot.tables.find(
(table) => table.name === `${kind}_mod_secondary_options`,
).rows;
const values = snapshot.tables.find(
(table) => table.name === `${kind}_mod_secondary_quality_values`,
).rows;
const optionIds = options.map((row) => String(row.stat_attribute_id ?? ''));
if (
optionIds.some((id) => !id) ||
new Set(optionIds).size !== optionIds.length ||
(optionIds.length !== 0 &&
(optionIds.length < 2 || optionIds.length > 50)) ||
values.length !== optionIds.length * 3
) {
throw new Error(
`The ISAC snapshot contains an incomplete shared ${kind} Mod secondary pool.`,
);
}
for (const statId of optionIds) {
const profile = values.filter(
(row) => String(row.stat_attribute_id) === statId,
);
if (
profile.length !== 3 ||
['Specialized', 'Superior', 'High-end'].some(
(quality) => !profile.some((row) => row.quality === quality),
) ||
profile.some((row) => {
const endpoints = [
row.level_1_min,
row.level_1_max,
row.level_20_min,
row.level_20_max,
];
return (
endpoints.some(
(value) => typeof value !== 'number' || !Number.isFinite(value),
) ||
endpoints[1] < endpoints[0] ||
endpoints[2] < endpoints[0] ||
endpoints[3] < endpoints[1] ||
endpoints[3] < endpoints[2]
);
})
) {
throw new Error(
`The ISAC snapshot contains an invalid shared ${kind} Mod secondary profile for ${statId}.`,
);
}
}
if (
values.some(
(row) => !optionIds.includes(String(row.stat_attribute_id ?? '')),
)
) {
throw new Error(
`The ISAC snapshot contains an orphaned shared ${kind} Mod secondary range.`,
);
}
}
for (const [profileName, qualities, requiredOptions] of [
['primary', ['Standard', 'Specialized', 'Superior', 'High-end'], 4],
['secondary', ['Specialized', 'Superior', 'High-end'], null],
]) {
const options = snapshot.tables.find(
(table) => table.name === `skill_chip_${profileName}_options`,
).rows;
const values = snapshot.tables.find(
(table) => table.name === `skill_chip_${profileName}_quality_values`,
).rows;
const optionIds = options.map((row) => String(row.stat_attribute_id ?? ''));
const validCardinality = requiredOptions
? optionIds.length === requiredOptions
: optionIds.length <= 50;
if (
!validCardinality ||
optionIds.some((id) => !id) ||
new Set(optionIds).size !== optionIds.length
) {
throw new Error(
`The ISAC snapshot contains an incomplete shared Skill Chip ${profileName} profile.`,
);
}
if (profileName === 'primary' && optionIds.length) {
const attributes = snapshot.tables.find(
(table) => table.name === 'stat_attributes',
).rows;
const expectedIds = attributes
.filter(
(row) =>
Number(row.active) === 1 &&
[
'Skill Duration',
'Skill Radius',
'Skill Cooldown Recovery',
'Skill Intensity',
].includes(String(row.name)),
)
.map((row) => String(row.id))
.sort();
if (
expectedIds.length !== 4 ||
optionIds
.slice()
.sort()
.some((id, index) => id !== expectedIds[index])
)
throw new Error(
'The ISAC snapshot does not use the four fixed Skill Chip primary Attributes.',
);
}
for (const statId of optionIds) {
const rows = values.filter(
(row) => String(row.stat_attribute_id ?? '') === statId,
);
if (!rows.length) continue;
if (
rows.length !== qualities.length ||
qualities.some(
(quality) => !rows.some((row) => row.quality === quality),
) ||
rows.some(
(row) =>
typeof row.min_value !== 'number' ||
!Number.isFinite(row.min_value) ||
typeof row.max_value !== 'number' ||
!Number.isFinite(row.max_value) ||
row.min_value < 0 ||
row.max_value < row.min_value,
)
) {
throw new Error(
`The ISAC snapshot contains an invalid shared Skill Chip ${profileName} profile for ${statId}.`,
);
}
}
if (
values.some(
(row) => !optionIds.includes(String(row.stat_attribute_id ?? '')),
)
)
throw new Error(
`The ISAC snapshot contains an orphaned shared Skill Chip ${profileName} range.`,
);
}
for (const icon of snapshot.icons) {
if (
typeof icon.storageKey !== 'string' ||
+5
View File
@@ -32,7 +32,9 @@ export const ISAC_DATA_TABLES = [
'skill_chip_sets',
'skill_chip_two_piece_bonuses',
'skill_chip_primary_options',
'skill_chip_primary_quality_values',
'skill_chip_secondary_options',
'skill_chip_secondary_quality_values',
'skill_chip_talents',
'skill_chip_talent_effects',
'weapon_mods',
@@ -55,6 +57,7 @@ export const ISAC_DATA_TABLES = [
'specialization_combat_effects',
'specialization_focuses',
'specialization_abilities',
'specialization_ability_skill_chip_links',
'specialization_ability_attacks',
'specialization_ability_effects',
'specialization_focus_abilities',
@@ -73,5 +76,7 @@ export const ISAC_DATA_TABLES = [
'managed_images',
];
export const ISAC_DATA_FORMAT_VERSION = 5;
export const quoteIdentifier = (identifier) =>
`"${String(identifier).replaceAll('"', '""')}"`;
+1 -1
View File
@@ -23,7 +23,7 @@ const images = [];
for (const row of rows) {
if (
typeof row.storage_key !== 'string' ||
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i.test(
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i.test(
row.storage_key,
)
) {
+16 -3
View File
@@ -14,6 +14,19 @@ if (!bundleArgument || !process.argv.includes('--confirm-merge')) {
process.exit(2);
}
const updateExisting = process.argv.includes('--update-existing');
const gameDataRenderSourceTypes = new Set([
'weapon_render',
'gear_render',
'weapon_mod_render',
'gear_mod_render',
'talent_render',
'brand_set_render',
'os_protocol_render',
'skill_chip_render',
'specialization_render',
'specialization_ability_render',
'specialization_node_render',
]);
const bundlePath = resolve(bundleArgument);
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
if (
@@ -30,21 +43,21 @@ for (const image of bundle.images) {
typeof image?.row?.id !== 'string' ||
!/^[0-9a-f-]{36}$/i.test(image.row.id) ||
typeof image.row.storage_key !== 'string' ||
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i.test(
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i.test(
image.row.storage_key,
) ||
typeof image.contentBase64 !== 'string'
) {
throw new Error('The bundle contains an invalid managed image.');
}
const isItemRender = ['weapon_render', 'gear_render'].includes(
const isGameDataRender = gameDataRenderSourceTypes.has(
image.row.source_entity_type,
);
if (
image.row.source_entity_type &&
(typeof image.row.source_entity_id !== 'string' ||
!/^[0-9a-f-]{36}$/i.test(image.row.source_entity_id) ||
(isItemRender
(isGameDataRender
? !image.row.storage_key.startsWith('library-')
: !image.row.storage_key.startsWith(
`${image.row.source_entity_type}-`,
+847 -24
View File
File diff suppressed because it is too large Load Diff
+166
View File
@@ -91,6 +91,172 @@ describe('article documents', () => {
).toBeNull();
});
it('accepts and canonicalizes metadata emitted by the Tiptap editor', () => {
const parsed = parseArticleDocument({
version: 1,
document: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Read more',
marks: [
{
type: 'link',
attrs: {
href: 'https://shd.build/news',
target: '_blank',
rel: 'noopener noreferrer nofollow',
class: null,
title: 'News archive',
},
},
],
},
],
},
{
type: 'orderedList',
attrs: { start: 1, type: null },
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'First item' }],
},
],
},
],
},
],
},
});
expect(parsed?.document.document.content).toEqual([
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Read more',
marks: [
{
type: 'link',
attrs: {
href: 'https://shd.build/news',
title: 'News archive',
},
},
],
},
],
},
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'First item' }],
},
],
},
],
},
]);
expect(parsed?.plainText).toContain('News archive');
});
it('preserves supported ordered-list numbering', () => {
const parsed = parseArticleDocument({
version: 1,
document: {
type: 'doc',
content: [
{
type: 'orderedList',
attrs: { start: 2, type: 'A' },
content: [
{
type: 'listItem',
content: [{ type: 'paragraph' }],
},
],
},
],
},
});
expect(parsed?.document.document.content?.[0]).toMatchObject({
type: 'orderedList',
attrs: { start: 2, type: 'A' },
});
expect(parseArticleDocument(parsed?.document)?.document).toEqual(
parsed?.document,
);
});
it('rejects unknown link metadata and non-default ordered lists', () => {
const documentWith = (content: unknown[]) => ({
version: 1,
document: { type: 'doc', content },
});
expect(
parseArticleDocument(
documentWith([
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'unsafe metadata',
marks: [
{
type: 'link',
attrs: {
href: 'https://shd.build',
onclick: 'alert(1)',
},
},
],
},
],
},
]),
),
).toBeNull();
expect(
parseArticleDocument(
documentWith([
{
type: 'orderedList',
attrs: { start: 0, type: 'decimal' },
content: [],
},
]),
),
).toBeNull();
expect(
parseArticleDocument(
documentWith([
{
type: 'orderedList',
attrs: { start: 1, type: 1 },
content: [],
},
]),
),
).toBeNull();
});
it('rejects duplicate embed keys and unknown envelope fields', () => {
const embed = {
type: 'buildEmbed',
+63 -5
View File
@@ -10,7 +10,7 @@ const EMBED_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{7,63}$/;
export type ArticleMark = {
type: 'bold' | 'italic' | 'code' | 'link';
attrs?: { href: string };
attrs?: { href: string; title?: string };
};
export type ArticleNode = {
@@ -53,7 +53,17 @@ const safeLink = (value: unknown) => {
}
};
function parseMarks(value: unknown): ArticleMark[] | null {
const nullableBoundedString = (value: unknown, maximum: number) =>
value === undefined ||
value === null ||
(typeof value === 'string' &&
value.length <= maximum &&
!/\p{Cc}/u.test(value));
function parseMarks(
value: unknown,
onLinkTitle: (title: string) => void,
): ArticleMark[] | null {
if (value === undefined) return [];
if (!Array.isArray(value) || value.length > 4) return null;
const seen = new Set<string>();
@@ -77,10 +87,31 @@ function parseMarks(value: unknown): ArticleMark[] | null {
!isRecord(candidate.attrs)
)
return null;
if (!exactKeys(candidate.attrs, ['href'])) return null;
if (
!exactKeys(candidate.attrs, [
'href',
'target',
'rel',
'class',
'title',
]) ||
!nullableBoundedString(candidate.attrs.target, 100) ||
!nullableBoundedString(candidate.attrs.rel, 500) ||
!nullableBoundedString(candidate.attrs.class, 500) ||
!nullableBoundedString(candidate.attrs.title, 500)
)
return null;
const href = safeLink(candidate.attrs.href);
if (!href) return null;
marks.push({ type: 'link', attrs: { href } });
const title =
typeof candidate.attrs.title === 'string'
? candidate.attrs.title.normalize('NFKC').trim()
: '';
if (title) onLinkTitle(title);
marks.push({
type: 'link',
attrs: { href, ...(title ? { title } : {}) },
});
}
return marks;
}
@@ -134,7 +165,9 @@ export function parseArticleDocument(value: unknown): {
if (/\p{Cc}/u.test(text.replace(/[\n\t]/g, ''))) return null;
textLength += [...text].length;
if (textLength > MAX_ARTICLE_TEXT_CHARACTERS) return null;
const marks = parseMarks(candidate.marks);
const marks = parseMarks(candidate.marks, (title) =>
textParts.push(title),
);
if (!marks) return null;
textParts.push(text);
return marks.length ? { type, text, marks } : { type, text };
@@ -211,6 +244,31 @@ export function parseArticleDocument(value: unknown): {
)
return null;
attrs = { level: Number(candidate.attrs.level) };
} else if (type === 'orderedList' && candidate.attrs !== undefined) {
if (
!isRecord(candidate.attrs) ||
!exactKeys(candidate.attrs, ['start', 'type'])
)
return null;
const start = candidate.attrs.start ?? 1;
const listType = candidate.attrs.type;
if (
!Number.isInteger(start) ||
Number(start) < 1 ||
Number(start) > 1_000_000 ||
(listType !== null &&
listType !== undefined &&
(typeof listType !== 'string' ||
!['1', 'a', 'A', 'i', 'I'].includes(listType)))
)
return null;
const canonicalType =
typeof listType === 'string' && listType !== '1' ? listType : undefined;
attrs = {
...(Number(start) === 1 ? {} : { start: Number(start) }),
...(canonicalType ? { type: canonicalType } : {}),
};
if (!Object.keys(attrs).length) attrs = undefined;
} else if (candidate.attrs !== undefined) {
if (!isRecord(candidate.attrs) || Object.keys(candidate.attrs).length)
return null;
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from 'vitest';
import { buildShareUrl, copyBuildLink, shareBuild } from './build-sharing';
describe('Build sharing', () => {
it('constructs Build URLs from slugs and preserves absolute URLs', () => {
expect(buildShareUrl('field-medic', 'https://shd.build')).toBe(
'https://shd.build/builds/field-medic',
);
expect(buildShareUrl('build name', 'https://shd.build/ignored')).toBe(
'https://shd.build/builds/build%20name',
);
expect(buildShareUrl('https://stage.shd.build/builds/test')).toBe(
'https://stage.shd.build/builds/test',
);
});
it('uses the native share API', async () => {
const share = vi.fn().mockResolvedValue(undefined);
await expect(
shareBuild(
{ share },
{ title: 'Field Medic', url: 'https://shd.build/builds/field-medic' },
),
).resolves.toEqual({ state: 'shared' });
expect(share).toHaveBeenCalledWith({
title: 'Field Medic',
text: 'View Field Medic on SHD.build.',
url: 'https://shd.build/builds/field-medic',
});
});
it('treats AbortError as cancellation', async () => {
const share = vi
.fn()
.mockRejectedValue(new DOMException('Cancelled', 'AbortError'));
await expect(
shareBuild(
{ share },
{ title: 'Build', url: 'https://shd.build/builds/a' },
),
).resolves.toEqual({ state: 'cancelled' });
});
it('reports unsupported and failed native sharing', async () => {
await expect(
shareBuild({}, { title: 'Build', url: 'https://shd.build/builds/a' }),
).resolves.toEqual({ state: 'unsupported' });
const error = new Error('Share failed');
await expect(
shareBuild(
{ share: vi.fn().mockRejectedValue(error) },
{ title: 'Build', url: 'https://shd.build/builds/a' },
),
).resolves.toEqual({ state: 'failed', error });
});
it('copies the Build URL to the clipboard', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
await expect(
copyBuildLink({ clipboard: { writeText } }, 'https://shd.build/builds/a'),
).resolves.toEqual({ state: 'copied' });
expect(writeText).toHaveBeenCalledWith('https://shd.build/builds/a');
});
it('reports unsupported and failed clipboard access', async () => {
await expect(
copyBuildLink({}, 'https://shd.build/builds/a'),
).resolves.toEqual({
state: 'unsupported',
});
const error = new Error('Clipboard denied');
await expect(
copyBuildLink(
{ clipboard: { writeText: vi.fn().mockRejectedValue(error) } },
'https://shd.build/builds/a',
),
).resolves.toEqual({ state: 'failed', error });
});
});
+72
View File
@@ -0,0 +1,72 @@
export type BuildShareNavigator = {
share?: (data: ShareData) => Promise<void>;
clipboard?: {
writeText: (text: string) => Promise<void>;
};
};
export type BuildShareResult =
| { state: 'shared' }
| { state: 'cancelled' }
| { state: 'unsupported' }
| { state: 'failed'; error: unknown };
export type BuildCopyResult =
| { state: 'copied' }
| { state: 'unsupported' }
| { state: 'failed'; error: unknown };
export function buildShareUrl(slugOrUrl: string, origin?: string): string {
const value = slugOrUrl.trim();
if (!value) throw new Error('A Build slug or URL is required.');
if (/^https?:\/\//i.test(value)) return new URL(value).toString();
const base = origin ?? globalThis.location?.origin;
if (!base) throw new Error('An origin is required to construct a Build URL.');
const slug = value.replace(/^\/+|\/+$/g, '');
return new URL(`/builds/${encodeURIComponent(slug)}`, base).toString();
}
export async function shareBuild(
navigatorApi: BuildShareNavigator,
input: { title: string; url: string },
): Promise<BuildShareResult> {
if (typeof navigatorApi.share !== 'function') return { state: 'unsupported' };
try {
await navigatorApi.share({
title: input.title,
text: `View ${input.title} on SHD.build.`,
url: input.url,
});
return { state: 'shared' };
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'name' in error &&
error.name === 'AbortError'
) {
return { state: 'cancelled' };
}
return { state: 'failed', error };
}
}
export async function copyBuildLink(
navigatorApi: BuildShareNavigator,
url: string,
): Promise<BuildCopyResult> {
if (typeof navigatorApi.clipboard?.writeText !== 'function') {
return { state: 'unsupported' };
}
try {
await navigatorApi.clipboard.writeText(url);
return { state: 'copied' };
} catch (error) {
return { state: 'failed', error };
}
}
+2 -1
View File
@@ -259,7 +259,7 @@ describe('loadout mechanic timeline', () => {
).toEqual([1, 2]);
});
it('falls back to a documented Tier I mechanic when equipment Tier II is absent', () => {
it('falls back to a documented Tier I mechanic when exotic Tier II is absent', () => {
const persistedRule = {
rule: {
id: 'tier-i-only',
@@ -327,6 +327,7 @@ describe('loadout mechanic timeline', () => {
const adapted = equippedConditionalProgram({
key: 'weapon:0:exotic-talent',
tier: 2,
isExotic: true,
rule: persistedRule,
});
+55 -417
View File
@@ -25,20 +25,20 @@ import {
type SkillCalculationRules,
type SkillModifierWindow,
} from '$lib/calculations/skill';
import { gearSlots, scaled, type LoadoutDraft } from '$lib/builder/loadout';
import { gearSlots, type LoadoutDraft } from '$lib/builder/loadout';
import {
applyAttributeValue,
catalogAttribute as attribute,
selectedFocus,
selectedNodeRanks,
summarizeLoadout,
type LoadoutCatalog,
type LoadoutSummary,
} from '$lib/builder/loadout-summary';
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: {
type Catalog = LoadoutCatalog & {
calculation?: {
revision: any | null;
stages: any[];
profiles: any[];
@@ -46,380 +46,16 @@ type Catalog = {
};
};
export type LoadoutSummary = {
stats: Array<{ id: string; value: number; sourceCount: number }>;
activeBonuses: any[];
equippedTalents: any[];
};
export { summarizeLoadout } from '$lib/builder/loadout-summary';
export type { LoadoutSummary } from '$lib/builder/loadout-summary';
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 applyAttributeValue(
catalog: Catalog,
current: number,
statId: string,
incoming: number,
) {
const stat = attribute(catalog, statId);
const combined =
stat?.calculation_type === 'Multiplicative' && stat?.unit === '%'
? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100
: current + incoming;
return stat?.cap_value === null || stat?.cap_value === undefined
? combined
: Math.min(combined, Number(stat.cap_value));
}
function unitValue(catalog: Catalog, value: unknown, statId: string) {
const numeric = Number(value);
const rendered = Number.isInteger(numeric)
? String(numeric)
: numeric.toFixed(2).replace(/\.?0+$/, '');
const unit = attribute(catalog, statId)?.unit ?? '';
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
}
function talentTier(catalog: Catalog, talentId: string, tier: number) {
const item = catalog.talents.find((entry) => entry.id === talentId);
return (
item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ??
item?.tiers[0]
);
}
function talentDescription(catalog: Catalog, talentId: string, tier: number) {
const item = catalog.talents.find((entry) => entry.id === talentId);
const configured = talentTier(catalog, talentId, tier);
if (!item || !configured) return '';
const template = String(item.template || item.name);
return template.replace(/\{(\d+)\}/g, (placeholder, raw) => {
const effect = configured.effects[Number(raw) - 1];
return effect
? unitValue(catalog, effect.value, effect.stat_attribute_id)
: placeholder;
});
}
export function summarizeLoadout(
draft: LoadoutDraft,
catalog: Catalog,
): LoadoutSummary {
const values = new Map<string, number>();
const sources = new Map<string, Set<string>>();
const add = (statId: string, value: unknown, source: string) => {
if (!statId || !Number.isFinite(Number(value))) return;
const stat = attribute(catalog, statId);
const current = values.get(statId) ?? 0;
const incoming = Number(value);
const combined =
stat?.calculation_type === 'Multiplicative' && stat?.unit === '%'
? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100
: current + incoming;
values.set(
statId,
stat?.cap_value === null || stat?.cap_value === undefined
? combined
: Math.min(combined, Number(stat.cap_value)),
);
if (!sources.has(statId)) sources.set(statId, new Set());
sources.get(statId)!.add(source);
const calculationCatalog = (catalog: Catalog) =>
catalog.calculation ?? {
revision: null,
stages: [],
profiles: [],
skillProfiles: [],
};
const byName = (name: string) =>
catalog.attributes.find(
(item) => item.name.toLocaleLowerCase() === name.toLocaleLowerCase(),
)?.id ?? '';
const brandCounts = new Map<string, number>();
const activeBonuses: any[] = [];
const equippedTalents: any[] = [];
for (const slot of gearSlots) {
const selection = draft.gear[slot];
const item = catalog.gear.find((entry) => entry.id === selection.itemId);
if (!item) continue;
const enabledAugments = catalog.gearAugments.filter(
(entry) =>
entry.gear_slot === slot &&
selection.augmentSlots.includes(Number(entry.augment_slot)),
);
const basePercentage = enabledAugments
.filter((entry) => entry.effect_type === 'Base Percentage')
.reduce((total, entry) => total + Number(entry.value), 0);
add(
byName('Health'),
selection.health * (1 + basePercentage / 100),
`${slot} base`,
);
if (item.base_armor_min !== null)
add(
byName('Armor'),
selection.armor * (1 + basePercentage / 100),
`${slot} base`,
);
for (const roll of selection.rolls)
add(roll.statId, roll.value, `${slot} roll`);
for (const key of coreKeys)
add(byName(key), selection[key], `${slot} core`);
if (selection.brandId)
brandCounts.set(
selection.brandId,
(brandCounts.get(selection.brandId) ?? 0) + 1,
);
for (const augment of enabledAugments.filter(
(entry) => entry.effect_type === 'Custom',
)) {
add(
augment.stat_attribute_id,
augment.value,
`${slot} augment ${augment.augment_slot}`,
);
}
if (selection.talentId) {
equippedTalents.push({
key: `gear:${slot}:${selection.talentId}`,
id: selection.talentId,
rule:
catalog.talents.find((entry) => entry.id === selection.talentId)
?.rule ?? null,
tier: Number(item.tier),
source: slot,
description: talentDescription(
catalog,
selection.talentId,
Number(item.tier),
),
});
}
}
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,
rule:
catalog.talents.find((entry) => entry.id === talentId)?.rule ?? null,
tier: selection.tier,
source: index ? 'Secondary weapon' : 'Primary weapon',
description: talentDescription(catalog, talentId, selection.tier),
});
}
}
const chipGroups = new Map<
string,
{ count: number; highEnd: number; item: any }
>();
for (let index = 0; index < 9; index += 1) {
const selection = draft.chips[index];
const item = catalog.chipSets.find((entry) => entry.id === selection.setId);
if (!item) continue;
add(
selection.primary.statId,
selection.primary.value,
`Skill Chip ${index + 1} primary`,
);
for (const roll of selection.secondary)
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item };
group.count += 1;
if (selection.quality === 'High-end') group.highEnd += 1;
chipGroups.set(item.id, group);
}
for (const group of chipGroups.values()) {
if (group.count >= 2 && group.item.twoPiece) {
const value = scaled(
group.item.twoPiece.min_value,
group.item.twoPiece.max_value,
group.highEnd,
3,
);
add(
group.item.twoPiece.stat_attribute_id,
value,
`${group.item.name} 2-piece`,
);
activeBonuses.push({
name: group.item.name,
pieces: 2,
count: group.count,
statId: group.item.twoPiece.stat_attribute_id,
value,
});
}
if (group.count === 3 && group.item.talent) {
equippedTalents.push({
key: `chips:${group.item.id}`,
id: '',
custom: group.item.talent,
rule: group.item.talent.rule ?? null,
tier: group.highEnd,
abilityId: group.item.specialization_ability_id ?? null,
source: '3 Skill Chips',
description: group.item.talent.description_template.replace(
/\{(\d+)\}/g,
(placeholder: string, raw: string) => {
const effect = group.item.talent.effects[Number(raw) - 1];
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,
rule: protocol.talent.rule ?? null,
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;
},
),
});
}
}
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,
@@ -441,7 +77,7 @@ function calculationStage(
) {
return stageKey as CalculationStage;
}
const stage = catalog.calculation.stages.find(
const stage = calculationCatalog(catalog).stages.find(
(item) => item.stage_key === stageKey,
);
if (!stage) return null;
@@ -462,8 +98,8 @@ function calculationBindings(
catalog: Catalog,
weapon: any,
): AttributeBinding[] {
return catalog.calculation.profiles
.filter(
return calculationCatalog(catalog)
.profiles.filter(
(item) =>
item.weapon_category === '*' ||
item.weapon_category === weapon?.category,
@@ -647,7 +283,7 @@ export function equippedConditionalProgram(
: 'base';
return (
conditionalProgram(equipped.rule, variant) ??
(equipmentTalent && variant === 'II'
(equipmentTalent && equipped.isExotic && variant === 'II'
? conditionalProgram(equipped.rule, 'I')
: null)
);
@@ -1211,25 +847,27 @@ function skillCalculationBindings(catalog: Catalog): SkillAttributeBinding[] {
'cooldown_recovery',
'duration',
]);
return (catalog.calculation.skillProfiles ?? []).flatMap((profile: any) => {
const stage =
profile.stage_key === 'engineering'
? 'skill_damage'
: String(profile.stage_key);
return supportedStages.has(stage)
? [
{
attributeId: String(profile.stat_attribute_id),
stage: stage as SkillAttributeBinding['stage'],
operator:
profile.operator === 'multiply'
? ('multiply' as const)
: ('add' as const),
weight: Number(profile.weight ?? 1),
},
]
: [];
});
return (calculationCatalog(catalog).skillProfiles ?? []).flatMap(
(profile: any) => {
const stage =
profile.stage_key === 'engineering'
? 'skill_damage'
: String(profile.stage_key);
return supportedStages.has(stage)
? [
{
attributeId: String(profile.stat_attribute_id),
stage: stage as SkillAttributeBinding['stage'],
operator:
profile.operator === 'multiply'
? ('multiply' as const)
: ('add' as const),
weight: Number(profile.weight ?? 1),
},
]
: [];
},
);
}
function loadoutSkillAbilities(
@@ -1348,7 +986,7 @@ function loadoutSkillDeploymentEvents(
Math.max(1, Number(draft.combatProfile.encounterSeconds)),
{
revisionId: String(
catalog.calculation.revision?.id ?? 'mechanic-timeline',
calculationCatalog(catalog).revision?.id ?? 'mechanic-timeline',
),
bindings,
},
@@ -1539,9 +1177,9 @@ export function evaluateLoadoutCombat(
);
const required = (weaponIndex: number) => {
const missing: string[] = [];
if (!catalog.calculation.revision)
if (!calculationCatalog(catalog).revision)
missing.push('An active ISAC calculation revision');
else if (!catalog.calculation.profiles.length)
else if (!calculationCatalog(catalog).profiles.length)
missing.push('Published attribute calculation profiles');
if (!draft.weapons[weaponIndex].itemId)
missing.push(`${weaponIndex ? 'Secondary' : 'Primary'} weapon`);
@@ -1570,8 +1208,8 @@ export function evaluateLoadoutCombat(
weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON',
status: 'insufficient',
missing,
revisionLabel: catalog.calculation.revision
? `RULES R${catalog.calculation.revision.version}`
revisionLabel: calculationCatalog(catalog).revision
? `RULES R${calculationCatalog(catalog).revision.version}`
: undefined,
};
}
@@ -1660,8 +1298,8 @@ export function evaluateLoadoutCombat(
directDamageTotal += outcome.appliedValue;
}
}
const calculationStages = catalog.calculation.stages
.map((stage) => calculationStage(catalog, String(stage.stage_key)))
const calculationStages = calculationCatalog(catalog)
.stages.map((stage) => calculationStage(catalog, String(stage.stage_key)))
.filter((stage): stage is CalculationStage => stage !== null);
const result = calculateCombat({
weapon: {
@@ -1693,7 +1331,7 @@ export function evaluateLoadoutCombat(
? `Calculation profile for ${attribute(catalog, item.slice(8))?.name ?? item.slice(8)}`
: item.replaceAll('.', ' '),
),
revisionLabel: `RULES R${catalog.calculation.revision.version}`,
revisionLabel: `RULES R${calculationCatalog(catalog).revision.version}`,
};
}
const timeline = buildWeaponMechanicTimeline(
@@ -1754,7 +1392,7 @@ export function evaluateLoadoutCombat(
mode: source.state.mode,
...mechanicSummary(source.evaluation),
})),
revisionLabel: `RULES R${catalog.calculation.revision.version}`,
revisionLabel: `RULES R${calculationCatalog(catalog).revision.version}`,
};
});
}
@@ -1764,7 +1402,7 @@ export function evaluateLoadoutSkillCombat(
catalog: Catalog,
summary = summarizeLoadout(draft, catalog),
): SkillCalculation {
const revisionId = String(catalog.calculation.revision?.id ?? '');
const revisionId = String(calculationCatalog(catalog).revision?.id ?? '');
const bindings = skillCalculationBindings(catalog);
const boundIds = new Set(bindings.map((binding) => binding.attributeId));
const attributes = Object.fromEntries(
+686
View File
@@ -0,0 +1,686 @@
import { describe, expect, it } from 'vitest';
import { createDraft } from './loadout.js';
import { summarizeLoadout, type LoadoutCatalog } from './loadout-summary.js';
const attribute = (
id: string,
name: string,
options: {
unit?: string;
calculationType?: string;
cap?: number | null;
} = {},
) => ({
id,
name,
category: 'Combat',
unit: options.unit ?? '',
calculation_type: options.calculationType ?? 'Additive',
cap_value: options.cap ?? null,
});
const catalog = (overrides: Partial<LoadoutCatalog> = {}): LoadoutCatalog => ({
attributes: [],
gear: [],
gearAugments: [],
brands: [],
weapons: [],
talents: [],
chipSets: [],
protocols: [],
specializations: [],
...overrides,
});
const stat = (summary: ReturnType<typeof summarizeLoadout>, id: string) =>
summary.stats.find((entry) => entry.id === id);
describe('summarizeLoadout', () => {
it('returns an empty deterministic summary for a partial draft', () => {
expect(summarizeLoadout(createDraft(), catalog())).toEqual({
stats: [],
abilityStats: [],
activeBonuses: [],
equippedTalents: [],
});
});
it('ignores saved rolls that do not reference a known Attribute', () => {
const draft = createDraft();
draft.weapons[0].itemId = 'weapon';
draft.weapons[0].rolls[0] = { statId: 'unknown', value: 999 };
const summary = summarizeLoadout(
draft,
catalog({
weapons: [
{
id: 'weapon',
name: 'Test Weapon',
exotic_talent_id: null,
baseBonuses: [],
},
],
}),
);
expect(summary.stats).toEqual([]);
});
it('combines additive and multiplicative Attributes, applies caps, and counts distinct sources', () => {
const draft = createDraft();
draft.weapons[0].itemId = 'weapon';
draft.weapons[0].rolls = [
{ statId: 'additive', value: 5 },
{ statId: 'multiplicative', value: 20 },
];
const summary = summarizeLoadout(
draft,
catalog({
attributes: [
attribute('additive', 'Additive'),
attribute('multiplicative', 'Multiplicative', {
unit: '%',
calculationType: 'Multiplicative',
}),
attribute('capped', 'Capped', { cap: 25 }),
],
weapons: [
{
id: 'weapon',
name: 'Test Weapon',
exotic_talent_id: null,
baseBonuses: [
{ stat_attribute_id: 'additive', value: 10 },
{ stat_attribute_id: 'multiplicative', value: 10 },
{ stat_attribute_id: 'capped', value: 30 },
],
},
],
}),
);
expect(stat(summary, 'additive')).toEqual({
id: 'additive',
value: 15,
sourceCount: 2,
});
expect(stat(summary, 'multiplicative')?.value).toBeCloseTo(32);
expect(stat(summary, 'multiplicative')?.sourceCount).toBe(2);
expect(stat(summary, 'capped')).toEqual({
id: 'capped',
value: 25,
sourceCount: 1,
});
});
it('activates every earned set bonus and records it as a stat source', () => {
const draft = createDraft();
for (const slot of ['Holster', 'Gloves'] as const) {
draft.gear[slot].itemId = `${slot}-gear`;
draft.gear[slot].brandId = 'brand';
}
const summary = summarizeLoadout(
draft,
catalog({
attributes: [attribute('set-stat', 'Set Stat', { unit: '%' })],
gear: [
{ id: 'Holster-gear', tier: 0, base_armor_min: null },
{ id: 'Gloves-gear', tier: 0, base_armor_min: null },
],
brands: [
{
id: 'brand',
name: 'Determinist',
effects: [
{
pieces_required: 1,
stat_attribute_id: 'set-stat',
value: 5,
},
{
pieces_required: 2,
stat_attribute_id: 'set-stat',
value: 10,
},
],
},
],
}),
);
expect(stat(summary, 'set-stat')).toEqual({
id: 'set-stat',
value: 15,
sourceCount: 2,
});
expect(summary.activeBonuses).toEqual([
expect.objectContaining({ pieces: 1, count: 2, value: 5 }),
expect.objectContaining({ pieces: 2, count: 2, value: 10 }),
]);
});
it('interpolates Skill Chip bonuses and Talent descriptions from High-end count', () => {
const draft = createDraft();
for (let index = 0; index < 3; index += 1) {
draft.chips[index].setId = 'chip-set';
draft.chips[index].quality = index === 0 ? 'High-end' : 'Superior';
}
const summary = summarizeLoadout(
draft,
catalog({
attributes: [attribute('skill-damage', 'Skill Damage', { unit: '%' })],
chipSets: [
{
id: 'chip-set',
name: 'Signal',
specialization_ability_id: 'ability',
twoPiece: {
stat_attribute_id: 'skill-damage',
min_value: 10,
max_value: 40,
},
talent: {
name: 'Signal Talent',
description_template: 'Gain {1} Skill Damage.',
effects: [
{
stat_attribute_id: 'skill-damage',
min_value: 15,
max_value: 45,
},
],
rule: null,
},
},
],
}),
);
expect(stat(summary, 'skill-damage')).toEqual({
id: 'skill-damage',
value: 20,
sourceCount: 1,
});
expect(summary.equippedTalents).toEqual([
expect.objectContaining({
key: 'chips:chip-set:ability',
tier: 1,
abilityId: 'ability',
description: 'Gain 25% Skill Damage.',
}),
]);
});
it('keeps Skill Chip primaries ability-scoped while adding secondaries to Agent stats', () => {
const draft = createDraft();
draft.specialization.focusId = 'focus-1';
draft.chips[0] = {
setId: 'chip-set',
quality: 'Superior',
primary: { statId: 'skill-duration', value: 12 },
secondary: [
{ statId: 'agent-stat', value: 4 },
{ statId: 'other-secondary', value: 6 },
],
};
const summary = summarizeLoadout(
draft,
catalog({
attributes: [
attribute('skill-duration', 'Skill Duration', { unit: '%' }),
attribute('agent-stat', 'Agent Stat', { unit: '%' }),
attribute('other-secondary', 'Other Secondary', { unit: '%' }),
],
chipSets: [
{
id: 'chip-set',
name: 'Signal',
specialization_ability_id: 'ability-2',
compatibleAbilityIds: ['ability-2', 'ability-1'],
twoPiece: null,
talent: null,
},
],
specializations: [
{
id: 'specialization-1',
focuses: [
{
id: 'focus-1',
abilities: [{ id: 'ability-1' }],
nodes: [],
},
],
},
],
}),
);
expect(stat(summary, 'skill-duration')).toBeUndefined();
expect(stat(summary, 'agent-stat')?.value).toBe(4);
expect(stat(summary, 'other-secondary')?.value).toBe(6);
expect(summary.abilityStats).toEqual([
{
abilityId: 'ability-1',
stats: [
{
id: 'skill-duration',
value: 12,
sourceCount: 1,
sources: [
{
source: 'Skill Chip 1 primary',
chipIndex: 0,
chipSetId: 'chip-set',
value: 12,
},
],
},
],
},
]);
});
it('ignores a shared Skill Chip after its ability link is removed', () => {
const draft = createDraft();
draft.specialization.focusId = 'focus-1';
draft.chips[0] = {
setId: 'chip-set',
quality: 'Standard',
primary: { statId: 'skill-duration', value: 12 },
secondary: [],
};
const summary = summarizeLoadout(
draft,
catalog({
attributes: [
attribute('skill-duration', 'Skill Duration', { unit: '%' }),
],
chipSets: [
{
id: 'chip-set',
name: 'Signal',
specialization_ability_id: 'ability-2',
compatibleAbilityIds: ['ability-2'],
twoPiece: null,
talent: null,
},
],
specializations: [
{
id: 'specialization-1',
focuses: [
{
id: 'focus-1',
abilities: [{ id: 'ability-1' }],
nodes: [],
},
],
},
],
}),
);
expect(summary.abilityStats).toEqual([]);
});
it.each([
[0, 10],
[1, 20],
[2, 30],
[3, 40],
])(
'scales a three-chip 2-piece bonus with %i High-end chips',
(highEndCount, expectedValue) => {
const draft = createDraft();
for (let index = 0; index < 3; index += 1) {
draft.chips[index].setId = 'chip-set';
draft.chips[index].quality =
index < highEndCount ? 'High-end' : 'Standard';
}
const summary = summarizeLoadout(
draft,
catalog({
attributes: [
attribute('skill-damage', 'Skill Damage', { unit: '%' }),
],
chipSets: [
{
id: 'chip-set',
name: 'Signal',
specialization_ability_id: 'ability',
twoPiece: {
stat_attribute_id: 'skill-damage',
min_value: 10,
max_value: 40,
},
talent: null,
},
],
}),
);
expect(stat(summary, 'skill-damage')?.value).toBe(expectedValue);
expect(summary.activeBonuses[0]).toMatchObject({
count: 3,
value: expectedValue,
});
},
);
it('scales Protocol primary stats and Talent descriptions independently', () => {
const draft = createDraft();
draft.protocol = { itemId: 'protocol', level: 50, talentUpgrades: 5 };
const summary = summarizeLoadout(
draft,
catalog({
attributes: [
attribute('health', 'Health'),
attribute('healing', 'Healing', { unit: '%' }),
],
protocols: [
{
id: 'protocol',
name: 'Survival',
primaryEffect: {
stat_attribute_id: 'health',
base_value: 100,
},
secondaryEffects: [{ stat_attribute_id: 'healing', value: 5 }],
talent: {
name: 'Recovery',
description_template: 'Restore {1}.',
effects: [{ stat_attribute_id: 'healing', base_value: 2.5 }],
rule: null,
},
},
],
}),
);
expect(stat(summary, 'health')?.value).toBe(200);
expect(stat(summary, 'healing')?.value).toBe(5);
expect(summary.equippedTalents[0]).toMatchObject({
key: 'protocol:protocol',
tier: 5,
description: 'Restore 5%.',
});
});
it('applies static specialization rank effects but excludes combat mechanic outcomes', () => {
const draft = createDraft();
draft.specialization.focusId = 'focus';
draft.specialization.nodeRanks.node = 2;
const summary = summarizeLoadout(
draft,
catalog({
attributes: [
attribute('static', 'Static'),
attribute('mechanic', 'Mechanic'),
],
specializations: [
{
id: 'specialization',
focuses: [
{
id: 'focus',
nodes: [
{
id: 'node',
name: 'Steady Hand',
ranks: [
{
rank: 2,
effects: [
{
effect_type: 'Attribute',
stat_attribute_id: 'static',
value: 12,
},
],
combatEffects: [
{
rule: {
clauses: [
{
outcomes: [
{
kind: 'attribute_modifier',
attributeId: 'mechanic',
value: 99,
},
],
},
],
},
},
],
},
],
},
],
},
],
},
],
}),
);
expect(stat(summary, 'static')).toEqual({
id: 'static',
value: 12,
sourceCount: 1,
});
expect(stat(summary, 'mechanic')).toBeUndefined();
});
it('uses a gear Talent tier independently from the gear tier', () => {
const draft = createDraft();
draft.gear.Holster.itemId = 'gear';
draft.gear.Holster.talentId = 'talent';
Object.assign(draft.gear.Holster, { talentTier: 2 });
const summary = summarizeLoadout(
draft,
catalog({
attributes: [attribute('bonus', 'Bonus', { unit: '%' })],
gear: [{ id: 'gear', tier: 0, base_armor_min: null }],
talents: [
{
id: 'talent',
name: 'Reinforced',
template: 'Gain {1}.',
tiers: [
{
tier: 'I',
effects: [{ stat_attribute_id: 'bonus', value: 10 }],
},
{
tier: 'II',
effects: [{ stat_attribute_id: 'bonus', value: 20 }],
},
],
},
],
}),
);
expect(summary.equippedTalents).toEqual([
expect.objectContaining({
id: 'talent',
tier: 2,
tierLabel: 'TIER II',
description: 'Gain 20%.',
}),
]);
});
it('uses each weapon Talent tier independently without applying mechanic outcomes to stats', () => {
const draft = createDraft();
draft.weapons[0].itemId = 'weapon';
draft.weapons[0].tier = 2;
draft.weapons[0].talent1Id = 'talent-one';
draft.weapons[0].talent2Id = 'talent-two';
Object.assign(draft.weapons[0], {
talent1Tier: 1,
talent2Tier: 2,
exoticTalentTier: 1,
});
const rule = {
clauses: [
{
outcomes: [
{
kind: 'attribute_modifier',
attributeId: 'talent-stat',
value: 100,
},
],
},
],
};
const summary = summarizeLoadout(
draft,
catalog({
attributes: [
attribute('talent-stat', 'Talent Stat', { unit: '%' }),
attribute('shots', 'Shots', { unit: 'Shots' }),
],
weapons: [
{
id: 'weapon',
name: 'Test Weapon',
exotic_talent_id: 'exotic-talent',
baseBonuses: [],
},
],
talents: [
{
id: 'talent-one',
name: 'Measured',
template: 'Gain {1} for {2}.',
tiers: [
{
tier: 'I',
effects: [
{ stat_attribute_id: 'talent-stat', value: 10 },
{ stat_attribute_id: 'shots', value: 2 },
],
},
{
tier: 'II',
effects: [
{ stat_attribute_id: 'talent-stat', value: 20 },
{ stat_attribute_id: 'shots', value: 3 },
],
},
],
rule,
},
{
id: 'talent-two',
name: 'Calibrated',
template: 'Gain {1}.',
tiers: [
{
tier: 'I',
effects: [{ stat_attribute_id: 'talent-stat', value: 30 }],
},
{
tier: 'II',
effects: [{ stat_attribute_id: 'talent-stat', value: 40 }],
},
],
},
{
id: 'exotic-talent',
name: 'Exotic Talent',
template: 'Gain {1}.',
tiers: [
{
tier: 'I',
effects: [{ stat_attribute_id: 'talent-stat', value: 50 }],
},
{
tier: 'II',
effects: [{ stat_attribute_id: 'talent-stat', value: 60 }],
},
],
},
],
}),
);
expect(summary.equippedTalents).toEqual([
expect.objectContaining({
id: 'talent-one',
tier: 1,
tierLabel: 'TIER I',
description: 'Gain 10% for 2 Shots.',
rule,
}),
expect.objectContaining({
id: 'talent-two',
tier: 2,
tierLabel: 'TIER II',
description: 'Gain 40%.',
}),
expect.objectContaining({
id: 'exotic-talent',
tier: 1,
tierLabel: 'TIER I',
description: 'Gain 50%.',
}),
]);
expect(stat(summary, 'talent-stat')).toBeUndefined();
});
it('uses the authored tier description when a Talent has no template row', () => {
const draft = createDraft();
draft.weapons[0].itemId = 'weapon';
draft.weapons[0].talent1Id = 'talent';
const summary = summarizeLoadout(
draft,
catalog({
attributes: [attribute('bonus', 'Bonus', { unit: '%' })],
weapons: [
{
id: 'weapon',
name: 'Test Weapon',
exotic_talent_id: null,
baseBonuses: [],
},
],
talents: [
{
id: 'talent',
name: 'Measured',
template: '',
tiers: [
{
tier: 'I',
description: 'Gain {1} while active.',
effects: [{ stat_attribute_id: 'bonus', value: 10 }],
},
],
},
],
}),
);
expect(summary.equippedTalents[0]?.description).toBe(
'Gain 10% while active.',
);
});
});
+551
View File
@@ -0,0 +1,551 @@
import {
gearSlots,
scaled,
type LoadoutDraft,
type TalentTier,
} from '$lib/builder/loadout';
export type LoadoutCatalog = {
attributes: any[];
gear: any[];
gearAugments: any[];
brands: any[];
weapons: any[];
talents: any[];
skillChipProfile?: {
primaryOptions: any[];
secondaryOptions: any[];
};
chipSets: any[];
protocols: any[];
specializations: any[];
};
export type LoadoutSummary = {
stats: Array<{ id: string; value: number; sourceCount: number }>;
abilityStats: Array<{
abilityId: string;
stats: Array<{
id: string;
value: number;
sourceCount: number;
sources: Array<{
source: string;
chipIndex: number;
chipSetId: string;
value: number;
}>;
}>;
}>;
activeBonuses: any[];
equippedTalents: any[];
};
const coreKeys = ['firepower', 'toughness', 'engineering'] as const;
export function selectedFocus(draft: LoadoutDraft, catalog: LoadoutCatalog) {
return catalog.specializations
.flatMap((specialization) =>
specialization.focuses.map((focus: any) => ({
...focus,
specialization,
})),
)
.find((focus: any) => focus.id === draft.specialization.focusId);
}
export function selectedNodeRanks(
draft: LoadoutDraft,
catalog: LoadoutCatalog,
) {
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,
),
},
]
: [];
}) ?? []
);
}
export function catalogAttribute(catalog: LoadoutCatalog, id: string) {
return catalog.attributes.find((item) => item.id === id);
}
export function applyAttributeValue(
catalog: LoadoutCatalog,
current: number,
statId: string,
incoming: number,
) {
const stat = catalogAttribute(catalog, statId);
const combined =
stat?.calculation_type === 'Multiplicative' && stat?.unit === '%'
? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100
: current + incoming;
return stat?.cap_value === null || stat?.cap_value === undefined
? combined
: Math.min(combined, Number(stat.cap_value));
}
function unitValue(catalog: LoadoutCatalog, value: unknown, statId: string) {
const numeric = Number(value);
const rendered = Number.isInteger(numeric)
? String(numeric)
: numeric.toFixed(2).replace(/\.?0+$/, '');
const unit = catalogAttribute(catalog, statId)?.unit ?? '';
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
}
function talentTier(
catalog: LoadoutCatalog,
talentId: string,
tier: TalentTier,
) {
const item = catalog.talents.find((entry) => entry.id === talentId);
return item?.tiers.find(
(entry: any) => entry.tier === (tier === 2 ? 'II' : 'I'),
);
}
function talentDescription(
catalog: LoadoutCatalog,
talentId: string,
tier: TalentTier,
) {
const item = catalog.talents.find((entry) => entry.id === talentId);
const configured = talentTier(catalog, talentId, tier);
if (!item || !configured) return '';
const template = String(item.template || configured.description || 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: LoadoutCatalog,
): LoadoutSummary {
const values = new Map<string, number>();
const sources = new Map<string, Set<string>>();
const add = (statId: string, value: unknown, source: string) => {
if (
!statId ||
!catalogAttribute(catalog, statId) ||
!Number.isFinite(Number(value))
)
return;
values.set(
statId,
applyAttributeValue(
catalog,
values.get(statId) ?? 0,
statId,
Number(value),
),
);
if (!sources.has(statId)) sources.set(statId, new Set());
sources.get(statId)!.add(source);
};
const byName = (name: string) =>
catalog.attributes.find(
(item) => item.name.toLocaleLowerCase() === name.toLocaleLowerCase(),
)?.id ?? '';
const brandCounts = new Map<string, number>();
const activeBonuses: any[] = [];
const equippedTalents: any[] = [];
const abilityValues = new Map<string, Map<string, number>>();
const abilitySources = new Map<
string,
Map<
string,
Array<{
source: string;
chipIndex: number;
chipSetId: string;
value: number;
}>
>
>();
const addAbilityStat = (
abilityId: string,
statId: string,
value: unknown,
chipIndex: number,
chipSetId: string,
) => {
if (
!abilityId ||
!statId ||
!catalogAttribute(catalog, statId) ||
!Number.isFinite(Number(value))
)
return;
if (!abilityValues.has(abilityId)) abilityValues.set(abilityId, new Map());
const valuesForAbility = abilityValues.get(abilityId)!;
valuesForAbility.set(
statId,
applyAttributeValue(
catalog,
valuesForAbility.get(statId) ?? 0,
statId,
Number(value),
),
);
if (!abilitySources.has(abilityId))
abilitySources.set(abilityId, new Map());
const sourcesForAbility = abilitySources.get(abilityId)!;
if (!sourcesForAbility.has(statId)) sourcesForAbility.set(statId, []);
sourcesForAbility.get(statId)!.push({
source: `Skill Chip ${chipIndex + 1} primary`,
chipIndex,
chipSetId,
value: Number(value),
});
};
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,
rule:
catalog.talents.find((entry) => entry.id === selection.talentId)
?.rule ?? null,
tier: selection.talentTier,
tierLabel: selection.talentTier === 2 ? 'TIER II' : 'TIER I',
isExotic: false,
source: slot,
description: talentDescription(
catalog,
selection.talentId,
selection.talentTier,
),
});
}
}
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`,
);
const equippedWeaponTalents = [
{
id: selection.talent1Id,
tier: selection.talent1Tier,
isExotic: false,
},
{
id: selection.talent2Id,
tier: selection.talent2Tier,
isExotic: false,
},
{
id: item.exotic_talent_id,
tier: selection.exoticTalentTier,
isExotic: true,
},
].filter((entry) => entry.id);
for (const equippedTalent of equippedWeaponTalents) {
equippedTalents.push({
key: `weapon:${index}:${equippedTalent.id}`,
id: equippedTalent.id,
rule:
catalog.talents.find((entry) => entry.id === equippedTalent.id)
?.rule ?? null,
tier: equippedTalent.tier,
tierLabel: equippedTalent.tier === 2 ? 'TIER II' : 'TIER I',
isExotic: equippedTalent.isExotic,
source: index ? 'Secondary weapon' : 'Primary weapon',
description: talentDescription(
catalog,
equippedTalent.id,
equippedTalent.tier,
),
});
}
}
const chipGroups = new Map<
string,
{ count: number; highEnd: number; item: any; abilityId: string }
>();
const focus = selectedFocus(draft, catalog);
const validChipRoll = (
kind: 'primaryOptions' | 'secondaryOptions',
quality: string,
roll: { statId: string; value: number },
) => {
if (!catalog.skillChipProfile) return true;
const range = catalog.skillChipProfile[kind].find(
(option) => option.statId === roll.statId,
)?.ranges?.[quality];
return (
range &&
roll.value >= Number(range.min) &&
roll.value <= Number(range.max)
);
};
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;
const slotAbilityId = String(
focus?.abilities?.[Math.floor(index / 3)]?.id ?? '',
);
const compatibleAbilityIds = item.compatibleAbilityIds ?? [
String(item.specialization_ability_id ?? ''),
];
if (slotAbilityId && !compatibleAbilityIds.includes(slotAbilityId))
continue;
const abilityId =
slotAbilityId || String(item.specialization_ability_id ?? '');
if (validChipRoll('primaryOptions', selection.quality, selection.primary))
addAbilityStat(
abilityId,
selection.primary.statId,
selection.primary.value,
index,
String(item.id),
);
for (const roll of selection.secondary)
if (validChipRoll('secondaryOptions', selection.quality, roll))
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
const groupKey = `${item.id}:${abilityId}`;
const group = chipGroups.get(groupKey) ?? {
count: 0,
highEnd: 0,
item,
abilityId,
};
group.count += 1;
if (selection.quality === 'High-end') group.highEnd += 1;
chipGroups.set(groupKey, 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}:${group.abilityId}`,
id: '',
custom: group.item.talent,
rule: group.item.talent.rule ?? null,
tier: group.highEnd,
abilityId: group.abilityId || null,
source: '3 Skill Chips',
description: group.item.talent.description_template.replace(
/\{(\d+)\}/g,
(placeholder: string, raw: string) => {
const effect = group.item.talent.effects[Number(raw) - 1];
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,
rule: protocol.talent.rule ?? null,
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;
},
),
});
}
}
return {
stats: [...values]
.map(([id, value]) => ({
id,
value,
sourceCount: sources.get(id)?.size ?? 0,
}))
.sort(
(left, right) =>
(catalogAttribute(catalog, left.id)?.category ?? '').localeCompare(
catalogAttribute(catalog, right.id)?.category ?? '',
) ||
(catalogAttribute(catalog, left.id)?.name ?? '').localeCompare(
catalogAttribute(catalog, right.id)?.name ?? '',
),
),
abilityStats: [...abilityValues].map(([abilityId, abilityStatValues]) => ({
abilityId,
stats: [...abilityStatValues]
.map(([id, value]) => {
const statSources = abilitySources.get(abilityId)?.get(id) ?? [];
return {
id,
value,
sourceCount: statSources.length,
sources: statSources,
};
})
.sort(
(left, right) =>
(catalogAttribute(catalog, left.id)?.category ?? '').localeCompare(
catalogAttribute(catalog, right.id)?.category ?? '',
) ||
(catalogAttribute(catalog, left.id)?.name ?? '').localeCompare(
catalogAttribute(catalog, right.id)?.name ?? '',
),
),
})),
activeBonuses,
equippedTalents,
};
}
+185 -2
View File
@@ -1,20 +1,91 @@
import { describe, expect, it } from 'vitest';
import { createDraft, parseLoadoutDraft } from './loadout.js';
import {
createDraft,
migrateLoadoutDraftV5,
parseLoadoutDraft,
skillChipSecondaryCapacity,
type SkillChipQuality,
} from './loadout.js';
const copyDraft = () => structuredClone(createDraft());
const talentTierFields = [
[
'gear talentTier',
(draft: ReturnType<typeof copyDraft>) =>
draft.gear.Holster as unknown as Record<string, unknown>,
'talentTier',
],
[
'weapon talent1Tier',
(draft: ReturnType<typeof copyDraft>) =>
draft.weapons[0] as unknown as Record<string, unknown>,
'talent1Tier',
],
[
'weapon talent2Tier',
(draft: ReturnType<typeof copyDraft>) =>
draft.weapons[0] as unknown as Record<string, unknown>,
'talent2Tier',
],
[
'weapon exoticTalentTier',
(draft: ReturnType<typeof copyDraft>) =>
draft.weapons[0] as unknown as Record<string, unknown>,
'exoticTalentTier',
],
] as const;
describe('parseLoadoutDraft', () => {
it('accepts a current draft and returns an independent clone', () => {
const draft = createDraft();
const parsed = parseLoadoutDraft(draft);
expect(parsed).toEqual(draft);
expect(parsed?.version).toBe(6);
expect(parsed?.chips[0]).toEqual({
setId: '',
quality: 'Standard',
primary: { statId: '', value: 0 },
secondary: [],
});
expect(parsed?.calculationMode).toBe('maximum');
expect(parsed).not.toBe(draft);
parsed!.gear.Holster.level = 1;
expect(draft.gear.Holster.level).toBe(50);
});
it('defaults every equipment Talent tier to Tier I', () => {
const draft = createDraft();
expect(
Object.values(draft.gear).map(
(selection) =>
(selection as unknown as Record<string, unknown>).talentTier,
),
).toEqual(Array(6).fill(1));
expect(
draft.weapons.map((selection) => {
const weapon = selection as unknown as Record<string, unknown>;
return [
weapon.talent1Tier,
weapon.talent2Tier,
weapon.exoticTalentTier,
];
}),
).toEqual([
[1, 1, 1],
[1, 1, 1],
]);
});
it('accepts Tier II for every equipment Talent selection', () => {
const draft = copyDraft();
for (const [, select, key] of talentTierFields) select(draft)[key] = 2;
expect(parseLoadoutDraft(draft)).toEqual(draft);
});
it('rejects the retired version 1 three-chip shape', () => {
const retired = copyDraft() as unknown as Record<string, unknown>;
retired.version = 1;
@@ -23,13 +94,125 @@ describe('parseLoadoutDraft', () => {
expect(parseLoadoutDraft(retired)).toBeNull();
});
it('rejects a version 4 draft that does not contain exactly nine chips', () => {
it('rejects the retired version 4 schema', () => {
const retired = copyDraft() as unknown as Record<string, unknown>;
retired.version = 4;
expect(parseLoadoutDraft(retired)).toBeNull();
});
it('rejects the retired version 5 schema', () => {
const retired = copyDraft() as unknown as Record<string, unknown>;
retired.version = 5;
expect(parseLoadoutDraft(retired)).toBeNull();
});
it('migrates version 5 equipment-derived Talent tiers once', () => {
const legacy = copyDraft() as unknown as Record<string, any>;
legacy.version = 5;
for (const selection of Object.values(legacy.gear) as Record<
string,
any
>[]) {
delete selection.talentTier;
}
for (const selection of legacy.weapons as Record<string, any>[]) {
delete selection.talent1Tier;
delete selection.talent2Tier;
delete selection.exoticTalentTier;
}
legacy.gear.Holster.itemId = 'tier-two-gear';
legacy.weapons[0].itemId = 'exotic-weapon';
legacy.weapons[0].tier = 2;
const migrated = migrateLoadoutDraftV5(legacy, {
gear: [{ id: 'tier-two-gear', tier: 2 }],
weapons: [{ id: 'exotic-weapon', exotic_talent_id: 'exotic-talent' }],
talents: [
{
id: 'exotic-talent',
tiers: [{ tier: 'I' }, { tier: 'II' }],
},
],
});
expect(migrated?.version).toBe(6);
expect(migrated?.gear.Holster.talentTier).toBe(2);
expect(migrated?.gear.Gloves.talentTier).toBe(1);
expect(migrated?.weapons[0]).toMatchObject({
talent1Tier: 2,
talent2Tier: 2,
exoticTalentTier: 2,
});
});
it('rejects a current draft that does not contain exactly nine chips', () => {
const draft = copyDraft();
draft.chips.pop();
expect(parseLoadoutDraft(draft)).toBeNull();
});
it.each(talentTierFields)('rejects a draft missing %s', (_, select, key) => {
const draft = copyDraft();
delete select(draft)[key];
expect(parseLoadoutDraft(draft)).toBeNull();
});
it.each(
talentTierFields.flatMap(([name, select, key]) =>
[0, 1.5, 3].map((value) => [name, value, select, key] as const),
),
)('rejects %s value %s', (_, value, select, key) => {
const draft = copyDraft();
select(draft)[key] = value;
expect(parseLoadoutDraft(draft)).toBeNull();
});
it.each([
['Standard', 0],
['Specialized', 1],
['Superior', 2],
['High-end', 2],
] as const)(
'accepts %s chips with exactly %i secondary rolls',
(quality, capacity) => {
const draft = copyDraft();
draft.chips[0].quality = quality;
draft.chips[0].primary = { statId: 'primary', value: 10 };
draft.chips[0].secondary = Array.from(
{ length: capacity },
(_, index) => ({ statId: `secondary-${index}`, value: index + 1 }),
);
expect(skillChipSecondaryCapacity[quality]).toBe(capacity);
expect(parseLoadoutDraft(draft)).toEqual(draft);
},
);
it.each([
['Standard', 1],
['Specialized', 0],
['Specialized', 2],
['Superior', 1],
['High-end', 3],
] as Array<[SkillChipQuality, number]>)(
'rejects %s chips with %i secondary rolls',
(quality, capacity) => {
const draft = copyDraft();
draft.chips[0].quality = quality;
draft.chips[0].secondary = Array.from(
{ length: capacity },
(_, index) => ({ statId: `secondary-${index}`, value: index + 1 }),
);
expect(parseLoadoutDraft(draft)).toBeNull();
},
);
it('rejects a draft missing the unified combat profile', () => {
const draft = copyDraft() as unknown as Record<string, unknown>;
delete draft.combatProfile;
+77 -9
View File
@@ -1,9 +1,24 @@
export type Roll = { statId: string; value: number };
export type TalentTier = 1 | 2;
export const skillChipQualities = [
'Standard',
'Specialized',
'Superior',
'High-end',
] as const;
export type SkillChipQuality = (typeof skillChipQualities)[number];
export const skillChipSecondaryCapacity: Record<SkillChipQuality, number> = {
Standard: 0,
Specialized: 1,
Superior: 2,
'High-end': 2,
};
export type GearSelection = {
itemId: string;
level: number;
brandId: string;
talentId: string;
talentTier: TalentTier;
health: number;
armor: number;
firepower: number;
@@ -21,14 +36,17 @@ export type WeaponSelection = {
toughness: number;
engineering: number;
talent1Id: string;
talent1Tier: TalentTier;
talent2Id: string;
talent2Tier: TalentTier;
exoticTalentTier: TalentTier;
rolls: [Roll, Roll];
};
export type ChipSelection = {
setId: string;
quality: 'Superior' | 'High-end';
quality: SkillChipQuality;
primary: Roll;
secondary: [Roll, Roll];
secondary: Roll[];
};
export type ProtocolSelection = {
itemId: string;
@@ -68,7 +86,7 @@ export type SpecializationSelection = {
nodeRanks: Record<string, number>;
};
export type LoadoutDraft = {
version: 4;
version: 6;
buildName: string;
calculationMode: CalculationMode;
gear: Record<string, GearSelection>;
@@ -105,6 +123,7 @@ export const blankGear = (): GearSelection => ({
level: 50,
brandId: '',
talentId: '',
talentTier: 1,
health: 0,
armor: 0,
firepower: 0,
@@ -122,14 +141,17 @@ export const blankWeapon = (): WeaponSelection => ({
toughness: 0,
engineering: 0,
talent1Id: '',
talent1Tier: 1,
talent2Id: '',
talent2Tier: 1,
exoticTalentTier: 1,
rolls: [blankRoll(), blankRoll()],
});
export const blankChip = (): ChipSelection => ({
setId: '',
quality: 'Superior',
quality: 'Standard',
primary: blankRoll(),
secondary: [blankRoll(), blankRoll()],
secondary: [],
});
export const defaultCombatProfile = (): CombatProfile => ({
encounterSeconds: 60,
@@ -148,7 +170,7 @@ export const defaultCombatProfile = (): CombatProfile => ({
extraHealthActive: false,
});
export const createDraft = (): LoadoutDraft => ({
version: 4,
version: 6,
buildName: 'Untitled loadout',
calculationMode: 'maximum',
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
@@ -174,6 +196,39 @@ export const createDraft = (): LoadoutDraft => ({
},
});
export function migrateLoadoutDraftV5(
value: unknown,
catalog: { gear: any[]; weapons: any[]; talents: any[] },
): LoadoutDraft | null {
if (!isRecord(value) || value.version !== 5) return null;
const migrated = structuredClone(value);
if (!isRecord(migrated.gear) || !Array.isArray(migrated.weapons)) return null;
for (const slot of gearSlots) {
const selection = migrated.gear[slot];
if (!isRecord(selection)) return null;
const item = catalog.gear.find((entry) => entry.id === selection.itemId);
selection.talentTier = Number(item?.tier) >= 2 ? 2 : 1;
}
for (const selection of migrated.weapons) {
if (!isRecord(selection)) return null;
const inheritedTier: TalentTier = Number(selection.tier) >= 2 ? 2 : 1;
selection.talent1Tier = inheritedTier;
selection.talent2Tier = inheritedTier;
const item = catalog.weapons.find((entry) => entry.id === selection.itemId);
const exoticTalent = catalog.talents.find(
(entry) => entry.id === item?.exotic_talent_id,
);
selection.exoticTalentTier =
inheritedTier === 2 &&
exoticTalent?.tiers?.some((entry: any) => entry.tier === 'II')
? 2
: 1;
}
migrated.version = 6;
return parseLoadoutDraft(migrated);
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
@@ -181,6 +236,8 @@ const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
keys.every((key) => key in value);
const isFiniteNumber = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value);
const isTalentTier = (value: unknown): value is TalentTier =>
value === 1 || value === 2;
const isNumberRecord = (value: unknown) =>
isRecord(value) &&
Object.values(value).every(
@@ -200,6 +257,7 @@ const isGearSelection = (value: unknown): value is GearSelection =>
'level',
'brandId',
'talentId',
'talentTier',
'health',
'armor',
'firepower',
@@ -211,6 +269,7 @@ const isGearSelection = (value: unknown): value is GearSelection =>
['itemId', 'brandId', 'talentId'].every(
(key) => typeof value[key] === 'string',
) &&
isTalentTier(value.talentTier) &&
['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every(
(key) => isFiniteNumber(value[key]),
) &&
@@ -228,12 +287,18 @@ const isWeaponSelection = (value: unknown): value is WeaponSelection =>
'toughness',
'engineering',
'talent1Id',
'talent1Tier',
'talent2Id',
'talent2Tier',
'exoticTalentTier',
'rolls',
]) &&
['itemId', 'talent1Id', 'talent2Id'].every(
(key) => typeof value[key] === 'string',
) &&
isTalentTier(value.talent1Tier) &&
isTalentTier(value.talent2Tier) &&
isTalentTier(value.exoticTalentTier) &&
['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every(
(key) => isFiniteNumber(value[key]),
) &&
@@ -242,9 +307,12 @@ const isChipSelection = (value: unknown): value is ChipSelection =>
isRecord(value) &&
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
typeof value.setId === 'string' &&
(value.quality === 'Superior' || value.quality === 'High-end') &&
skillChipQualities.some((quality) => value.quality === quality) &&
isRoll(value.primary) &&
isRollPair(value.secondary);
Array.isArray(value.secondary) &&
value.secondary.length ===
skillChipSecondaryCapacity[value.quality as SkillChipQuality] &&
value.secondary.every(isRoll);
const isProtocolSelection = (value: unknown): value is ProtocolSelection =>
isRecord(value) &&
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) &&
@@ -353,7 +421,7 @@ export function parseLoadoutDraft(value: unknown): LoadoutDraft | null {
'skillActivations',
'specialization',
]) ||
value.version !== 4 ||
value.version !== 6 ||
typeof value.buildName !== 'string' ||
!['maximum', 'custom'].includes(String(value.calculationMode))
)
+146
View File
@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest';
import {
calculateWeaponBaseline,
type WeaponBaselineInput,
} from './weapon-baseline.js';
const input = (
overrides: Partial<WeaponBaselineInput> = {},
): WeaponBaselineInput => ({
weaponId: 'weapon-17',
weaponName: 'Evidence Rifle',
weaponLabel: 'PRIMARY',
selectedDamage: 12.75,
rpm: 137.5,
magazine: 31,
reloadSeconds: 2.35,
...overrides,
});
describe('calculateWeaponBaseline', () => {
it('preserves decimal inputs in the selected and rated metrics', () => {
const baseline = calculateWeaponBaseline(input());
expect(baseline.selectedDamage).toEqual({ value: 12.75, missing: [] });
expect(baseline.ratedBodyHitThroughput).toEqual({
value: 29.21875,
missing: [],
});
});
it('keeps rated throughput available when reload inputs are missing', () => {
const baseline = calculateWeaponBaseline(
input({ magazine: undefined, reloadSeconds: undefined }),
);
expect(baseline.ratedBodyHitThroughput).toEqual({
value: 29.21875,
missing: [],
});
expect(baseline.magazineCycleBodyHitThroughput).toEqual({
value: null,
missing: [
'magazine is unavailable for weapon weapon-17',
'reloadSeconds is unavailable for weapon weapon-17',
],
});
});
it('uses the complete magazine-cycle formula without rounding', () => {
const baseline = calculateWeaponBaseline(
input({
selectedDamage: 13.25,
rpm: 97.5,
magazine: 17.5,
reloadSeconds: 2.75,
}),
);
const expected = (13.25 * 17.5) / (17.5 / (97.5 / 60) + 2.75);
expect(baseline.magazineCycleBodyHitThroughput).toEqual({
value: expected,
missing: [],
});
});
it('uses selected damage exactly once at the no-double-scaling boundary', () => {
const baseline = calculateWeaponBaseline(
input({ selectedDamage: 12.5, rpm: 120, magazine: 10, reloadSeconds: 5 }),
);
expect(baseline.selectedDamage.value).toBe(12.5);
expect(baseline.ratedBodyHitThroughput.value).toBe(12.5 * (120 / 60));
expect(baseline.magazineCycleBodyHitThroughput.value).toBe(
(12.5 * 10) / (10 / (120 / 60) + 5),
);
expect(baseline.ratedBodyHitThroughput.value).not.toBe(
12.5 * 12.5 * (120 / 60),
);
expect(baseline.magazineCycleBodyHitThroughput.value).not.toBe(12.5 * 12.5);
});
it.each([
null,
undefined,
0,
-1,
Number.NaN,
Number.POSITIVE_INFINITY,
Number.NEGATIVE_INFINITY,
])('rejects invalid selected damage: %s', (selectedDamage) => {
const baseline = calculateWeaponBaseline(input({ selectedDamage }));
expect(baseline.selectedDamage.value).toBeNull();
expect(baseline.ratedBodyHitThroughput.value).toBeNull();
expect(baseline.magazineCycleBodyHitThroughput.value).toBeNull();
expect(baseline.selectedDamage.missing[0]).toContain('selectedDamage');
expect(baseline.selectedDamage.missing[0]).toContain('weapon-17');
for (const metric of [
baseline.selectedDamage,
baseline.ratedBodyHitThroughput,
baseline.magazineCycleBodyHitThroughput,
]) {
expect(metric.value).toBeNull();
expect(metric.value).not.toBeNaN();
expect(metric.value).not.toBe(Infinity);
expect(metric.value).not.toBe(-Infinity);
}
});
it.each([
['rpm', null],
['rpm', 0],
['rpm', Number.NaN],
['magazine', -1],
['reloadSeconds', Number.POSITIVE_INFINITY],
] as const)(
'makes only dependent metrics unavailable for invalid %s',
(field, value) => {
const baseline = calculateWeaponBaseline(input({ [field]: value }));
expect(baseline.selectedDamage.value).toBe(12.75);
if (field === 'rpm') {
expect(baseline.ratedBodyHitThroughput.value).toBeNull();
expect(baseline.magazineCycleBodyHitThroughput.value).toBeNull();
} else if (field === 'magazine' || field === 'reloadSeconds') {
expect(baseline.ratedBodyHitThroughput.value).toBe(29.21875);
expect(baseline.magazineCycleBodyHitThroughput.value).toBeNull();
}
const dependent =
field === 'rpm'
? [
baseline.ratedBodyHitThroughput,
baseline.magazineCycleBodyHitThroughput,
]
: [baseline.magazineCycleBodyHitThroughput];
for (const metric of dependent) {
expect(metric.value).not.toBeNaN();
expect(metric.value).not.toBe(Infinity);
expect(metric.value).not.toBe(-Infinity);
expect(metric.missing[0]).toContain(field);
expect(metric.missing[0]).toContain('weapon-17');
}
},
);
});
+112
View File
@@ -0,0 +1,112 @@
type NumericSource = number | null | undefined;
export type WeaponBaselineInput = {
weaponId: string;
weaponName: string;
weaponLabel: string;
selectedDamage?: NumericSource;
rpm?: NumericSource;
magazine?: NumericSource;
reloadSeconds?: NumericSource;
};
export type BaselineMetric = {
value: number | null;
missing: string[];
};
export type WeaponBaseline = {
weaponId: string;
weaponName: string;
weaponLabel: string;
selectedDamage: BaselineMetric;
ratedBodyHitThroughput: BaselineMetric;
magazineCycleBodyHitThroughput: BaselineMetric;
};
const isPositiveFinite = (value: NumericSource): value is number =>
typeof value === 'number' && Number.isFinite(value) && value > 0;
const missingField = (field: string, weaponId: string): string =>
`${field} is unavailable for weapon ${weaponId}`;
const unavailableMetric = (
fields: string[],
weaponId: string,
): BaselineMetric => ({
value: null,
missing: fields.map((field) => missingField(field, weaponId)),
});
const calculatedMetric = (
value: number,
metricField: string,
weaponId: string,
): BaselineMetric =>
Number.isFinite(value)
? { value, missing: [] }
: { value: null, missing: [missingField(metricField, weaponId)] };
export function calculateWeaponBaseline(
input: WeaponBaselineInput,
): WeaponBaseline {
const damageAvailable = isPositiveFinite(input.selectedDamage);
const rpmAvailable = isPositiveFinite(input.rpm);
const magazineAvailable = isPositiveFinite(input.magazine);
const reloadAvailable = isPositiveFinite(input.reloadSeconds);
const damage = damageAvailable ? (input.selectedDamage as number) : null;
const rpm = rpmAvailable ? (input.rpm as number) : null;
const magazine = magazineAvailable ? (input.magazine as number) : null;
const reloadSeconds = reloadAvailable
? (input.reloadSeconds as number)
: null;
const selectedDamage =
damage !== null
? calculatedMetric(damage, 'selectedDamage', input.weaponId)
: unavailableMetric(['selectedDamage'], input.weaponId);
const ratedBodyHitThroughput =
damage !== null && rpm !== null
? calculatedMetric(
(damage * rpm) / 60,
'ratedBodyHitThroughput',
input.weaponId,
)
: unavailableMetric(
[
...(!damageAvailable ? ['selectedDamage'] : []),
...(!rpmAvailable ? ['rpm'] : []),
],
input.weaponId,
);
const magazineCycleBodyHitThroughput =
damage !== null &&
rpm !== null &&
magazine !== null &&
reloadSeconds !== null
? calculatedMetric(
(damage * magazine) / (magazine / (rpm / 60) + reloadSeconds),
'magazineCycleBodyHitThroughput',
input.weaponId,
)
: unavailableMetric(
[
...(!damageAvailable ? ['selectedDamage'] : []),
...(!rpmAvailable ? ['rpm'] : []),
...(!magazineAvailable ? ['magazine'] : []),
...(!reloadAvailable ? ['reloadSeconds'] : []),
],
input.weaponId,
);
return {
weaponId: input.weaponId,
weaponName: input.weaponName,
weaponLabel: input.weaponLabel,
selectedDamage,
ratedBodyHitThroughput,
magazineCycleBodyHitThroughput,
};
}
+234 -5
View File
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
import {
currentHealthPercentBandPredicate,
eventFieldComparisonPredicate,
mechanicTargetingForMode,
mechanicTargetingMode,
parseConditionalMechanicDraft,
readCurrentHealthPercentBand,
readEventFieldComparison,
@@ -200,6 +202,110 @@ describe('conditional mechanic authoring payload', () => {
});
});
it('exposes assigned-ability activity for kill and assist event conditions', () => {
expect(registeredEventFields('enemy_killed')).toEqual(
expect.arrayContaining([
{
path: 'values.assignedAbilityActive',
label: 'Assigned ability active',
type: 'boolean',
},
]),
);
expect(registeredEventFields('assist')).toEqual(
expect.arrayContaining([
{
path: 'values.assignedAbilityActive',
label: 'Assigned ability active',
type: 'boolean',
},
]),
);
const value = draft();
value.clauses[0].event = 'enemy_killed';
value.clauses[0].recipeKey = undefined;
value.clauses[0].reducer = { kind: 'none' };
value.clauses[0].if = eventFieldComparisonPredicate({
path: 'values.assignedAbilityActive',
operator: 'eq',
value: true,
});
const result = parseConditionalMechanicDraft(value, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage']),
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.programs.I.clauses[0]).toMatchObject({
when: 'enemy_killed',
if: value.clauses[0].if,
});
});
it('parses deterministic outcome and clause proc chances with ability mutations', () => {
const value = draft();
value.clauses[0].event = 'skill_hit';
value.clauses[0].recipeKey = undefined;
value.clauses[0].procChancePercent = 10;
value.clauses[0].requiresAssignedAbilityActive = true;
value.clauses[0].outcomes[0].probabilityPercent = 29;
value.clauses[0].outcomes[0].statePool = undefined;
value.clauses[0].outcomes[0].perStack = false;
value.pools = [
{
key: 'extension-stacks',
label: 'Extension stacks',
stateKind: 'stack',
scope: 'agent',
initial: 0,
min: 0,
max: 8,
},
{
key: 'extension-cooldown',
label: 'Extension cooldown',
stateKind: 'flag',
scope: 'agent',
initial: 0,
min: 0,
max: 1,
expiresAfterSeconds: 15,
},
];
value.clauses[0].mutations = [
{ kind: 'add', pool: 'extension-stacks', value: 1 },
{ kind: 'extend_duration', target: 'assigned_ability', seconds: 0.3 },
{
kind: 'start_cooldown_on_max',
stackPool: 'extension-stacks',
cooldownPool: 'extension-cooldown',
},
];
const result = parseConditionalMechanicDraft(value, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage']),
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.programs.I.clauses[0]).toMatchObject({
procChancePercent: 10,
requiresAssignedAbilityActive: true,
outcomes: [{ probabilityPercent: 29 }],
});
expect(result.value.programs.I.clauses[0].mutations).toEqual(
expect.arrayContaining([
{ kind: 'extend_duration', target: 'assigned_ability', seconds: 0.3 },
{
kind: 'start_cooldown_on_max',
stackPool: 'extension-stacks',
cooldownPool: 'extension-cooldown',
},
]),
);
});
it('rejects an unregistered Extra Health event field deterministically', () => {
const value = draft();
value.clauses[0].event = 'extra_health_changed';
@@ -685,9 +791,9 @@ describe('conditional mechanic authoring payload', () => {
});
});
it('rejects invalid pre-event timing for current Health percentage references', () => {
const invalid = draft();
invalid.clauses[0].if = {
it('preserves pre-event timing for current Health percentage references', () => {
const value = draft();
value.clauses[0].if = {
kind: 'compare',
left: {
source: 'combat',
@@ -697,11 +803,13 @@ describe('conditional mechanic authoring payload', () => {
operator: 'lt',
right: 50,
};
const result = parseConditionalMechanicDraft(invalid, {
const result = parseConditionalMechanicDraft(value, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage']),
});
expect(result.ok).toBe(false);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.draft.clauses[0].if).toEqual(value.clauses[0].if);
});
it('accepts compatible radius targeting and rejects incompatible scopes', () => {
@@ -735,4 +843,125 @@ describe('conditional mechanic authoring payload', () => {
);
}
});
it('exposes aiming and round-trips event-target explosion editor state', () => {
expect(registeredEventFields('weapon_hit')).toContainEqual({
path: 'values.aiming',
label: 'Aiming down sights',
type: 'boolean',
});
const targeting = mechanicTargetingForMode('event_target_explosion');
expect(targeting).toEqual({
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
});
expect(mechanicTargetingMode(targeting)).toBe('event_target_explosion');
expect(mechanicTargetingForMode('none', targeting)).toBeUndefined();
const valid = draft();
valid.clauses[0].if = eventFieldComparisonPredicate({
path: 'values.aiming',
operator: 'eq',
value: true,
});
valid.clauses[0].outcomes[0] = {
...valid.clauses[0].outcomes[0],
kind: 'direct_damage',
scope: 'target',
phase: 'after_event',
attributeId: undefined,
targeting,
};
const parsed = parseConditionalMechanicDraft(valid, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage']),
});
expect(parsed.ok).toBe(true);
if (parsed.ok) {
expect(parsed.value.draft.clauses[0].outcomes[0].targeting).toEqual(
targeting,
);
}
});
it('rejects incomplete, mis-scoped, and non-event area damage', () => {
for (const configure of [
(candidate: ConditionalMechanicDraftV1) => {
candidate.clauses[0].outcomes[0].targeting = {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 0,
};
},
(candidate: ConditionalMechanicDraftV1) => {
candidate.clauses[0].outcomes[0].scope = 'self';
},
(candidate: ConditionalMechanicDraftV1) => {
candidate.clauses[0].outcomes[0].phase = 'while_active';
},
(candidate: ConditionalMechanicDraftV1) => {
candidate.clauses[0].event = 'enemy_killed';
},
]) {
const invalid = draft();
invalid.clauses[0].outcomes[0] = {
...invalid.clauses[0].outcomes[0],
kind: 'direct_damage',
scope: 'target',
phase: 'after_event',
attributeId: undefined,
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
},
};
configure(invalid);
expect(
parseConditionalMechanicDraft(invalid, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage']),
}).ok,
).toBe(false);
}
});
it('requires explicit equality for optional aiming evidence', () => {
for (const predicate of [
{
kind: 'compare' as const,
left: { source: 'event' as const, path: 'values.aiming' },
operator: 'ne' as const,
right: true,
},
{
kind: 'not' as const,
predicate: {
kind: 'compare' as const,
left: { source: 'event' as const, path: 'values.aiming' },
operator: 'eq' as const,
right: true,
},
},
{
kind: 'compare' as const,
left: { source: 'event' as const, path: 'values.aiming' },
operator: 'ne' as const,
right: { source: 'combat' as const, path: 'currentHealth' },
},
]) {
const invalid = draft();
invalid.clauses[0].if = predicate;
expect(
parseConditionalMechanicDraft(invalid, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage']),
}).ok,
).toBe(false);
}
});
});
+115 -25
View File
@@ -1,12 +1,14 @@
import {
MECHANIC_EVENT_KINDS,
MECHANIC_EVENT_REGISTRY,
mechanicOutcomeTargetingError,
validateMechanicProgram,
type ClauseMutation,
type MechanicClause,
type MechanicEventKind,
type MechanicOutcome,
type MechanicOutcomeScope,
type MechanicOutcomeTargeting,
type MechanicProgram,
type MechanicResource,
type NumericExpression,
@@ -198,6 +200,44 @@ export function registeredEventFields(event: MechanicEventKind) {
return MECHANIC_EVENT_REGISTRY[event].fields ?? [];
}
export type MechanicTargetingMode =
| 'none'
| 'allies_within_radius'
| 'enemies_within_radius'
| 'event_target_explosion';
export function mechanicTargetingMode(
targeting: MechanicOutcomeTargeting | undefined,
): MechanicTargetingMode {
if (!targeting) return 'none';
if (targeting.kind === 'event_target_area') return 'event_target_explosion';
return targeting.population === 'allies'
? 'allies_within_radius'
: 'enemies_within_radius';
}
export function mechanicTargetingForMode(
mode: MechanicTargetingMode,
current?: MechanicOutcomeTargeting,
): MechanicOutcomeTargeting | undefined {
if (mode === 'none') return undefined;
const radiusMeters =
current?.radiusMeters ?? (mode === 'event_target_explosion' ? 5 : 20);
if (mode === 'event_target_explosion') {
return {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters,
};
}
return {
kind: 'within_radius',
population: mode === 'allies_within_radius' ? 'allies' : 'enemies',
radiusMeters,
};
}
export type StatePoolThresholdComparison = {
pool: string;
operator: PredicateComparisonOperator;
@@ -258,6 +298,7 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = {
'maxStacks',
'targeting',
'descriptionSlot',
'probabilityPercent',
],
phase: ['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active'],
kind: [
@@ -291,6 +332,25 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = {
continuous:
'Valid only for restoration. The value is the total restoration, delivered at a constant rate across durationSeconds. Each activation creates an independent stream; overlapping streams add and overheal is lost when it occurs.',
},
probabilitySemantics:
'probabilityPercent is an optional inclusive 0 through 100 percentage. In custom simulation, each eligible outcome rolls independently from the evaluation random stream. Maximum mode resolves every nonzero probability as successful.',
targeting: {
withinRadius: {
kind: 'within_radius',
population: ['allies', 'enemies'],
radiusMeters: 'positive finite number',
semantics:
'Legacy radius metadata. It preserves authored range but does not select or fan out runtime targets.',
},
eventTargetArea: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 'positive finite number',
semantics:
'Valid only for target-scoped direct damage in on_event or after_event phases on a weapon_hit clause. The triggering event must supply an authoritative event-target area sample. Every valid enemy at or inside the radius receives one equal damage emission, ordered by entity ID.',
},
},
} as const;
const phases = new Set<OutcomePhase>(
CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.phase,
@@ -366,11 +426,14 @@ function isReference(value: unknown): value is ValueReference {
Boolean(value.path.trim()) &&
(value.timing === undefined ||
value.timing === 'pre_event' ||
value.timing === 'current') &&
!(value.path === 'currentHealthPercent' && value.timing === 'pre_event')
value.timing === 'current')
);
case 'equipment':
return typeof value.path === 'string' && Boolean(value.path.trim());
case 'assigned_ability':
return (
value.path === 'active' || value.path === 'remainingDurationSeconds'
);
case 'attribute':
return (
typeof value.attributeId === 'string' &&
@@ -527,6 +590,17 @@ function isMutation(value: unknown): value is ClauseMutation {
isExpression(value.value))
);
}
if (value.kind === 'extend_duration') {
return value.target === 'assigned_ability' && isExpression(value.seconds);
}
if (value.kind === 'start_cooldown_on_max') {
return (
typeof value.stackPool === 'string' &&
Boolean(value.stackPool.trim()) &&
typeof value.cooldownPool === 'string' &&
Boolean(value.cooldownPool.trim())
);
}
return (
['add', 'set', 'consume', 'derive'].includes(value.kind) &&
typeof value.pool === 'string' &&
@@ -724,30 +798,21 @@ function parseOutcome(
) {
errors.push(`${path} description slot must be a positive integer.`);
}
if (value.targeting !== undefined) {
if (
!object(value.targeting) ||
value.targeting.kind !== 'within_radius' ||
!['allies', 'enemies'].includes(String(value.targeting.population)) ||
!finite(value.targeting.radiusMeters) ||
value.targeting.radiusMeters <= 0
) {
errors.push(`${path} has invalid radius targeting.`);
} else if (
value.targeting.population === 'allies' &&
value.scope !== 'allies' &&
value.scope !== 'self_and_allies'
) {
errors.push(
`${path} ally radius targeting requires allies or self-and-allies scope.`,
);
} else if (
value.targeting.population === 'enemies' &&
value.scope !== 'target'
) {
errors.push(`${path} enemy radius targeting requires target scope.`);
}
if (
value.probabilityPercent !== undefined &&
(!finite(value.probabilityPercent) ||
value.probabilityPercent < 0 ||
value.probabilityPercent > 100)
) {
errors.push(`${path} probability must be from 0 through 100 percent.`);
}
const targetingError = mechanicOutcomeTargetingError({
kind,
scope: value.scope as MechanicOutcomeScope,
phase: value.phase as OutcomePhase,
targeting: value.targeting,
});
if (targetingError) errors.push(`${path} ${targetingError}`);
if (!object(value.variants)) {
errors.push(`${path} variants must be an object.`);
return null;
@@ -828,6 +893,22 @@ function parseClauses(
if (candidate.weight !== undefined && !isExpression(candidate.weight)) {
errors.push(`${path} has an invalid weight expression.`);
}
if (
candidate.procChancePercent !== undefined &&
(!finite(candidate.procChancePercent) ||
candidate.procChancePercent < 0 ||
candidate.procChancePercent > 100)
) {
errors.push(`${path} proc chance must be from 0 through 100 percent.`);
}
if (
candidate.requiresAssignedAbilityActive !== undefined &&
typeof candidate.requiresAssignedAbilityActive !== 'boolean'
) {
errors.push(
`${path} assigned-ability activity requirement must be a boolean.`,
);
}
if (
candidate.cooldownSeconds !== undefined &&
(!finite(candidate.cooldownSeconds) || candidate.cooldownSeconds <= 0)
@@ -902,6 +983,15 @@ function programsForDraft(
? {}
: { cooldownSeconds: clause.cooldownSeconds }),
...(clause.weight === undefined ? {} : { weight: clause.weight }),
...(clause.procChancePercent === undefined
? {}
: { procChancePercent: clause.procChancePercent }),
...(clause.requiresAssignedAbilityActive === undefined
? {}
: {
requiresAssignedAbilityActive:
clause.requiresAssignedAbilityActive,
}),
mutations: clause.mutations,
outcomes: clause.outcomes.map(
({
@@ -200,6 +200,143 @@ describe('conditional-effect persistence adapter', () => {
});
});
it('preserves event-target area damage semantics on an outcome', () => {
const result = adaptConditionalEffectProgram(
{
rule: rule(),
statePools: [],
clauses: [
clause({
trigger_id: null,
event_kind: 'weapon_hit',
state_pool_id: null,
mutations_json: '[]',
}),
],
outcomes: [
outcome({
application_kind: 'direct_damage',
effect_scope: 'target',
stat_attribute_id: null,
application_phase: 'after_event',
state_pool_id: null,
targeting_json: JSON.stringify({
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
}),
}),
],
triggers: [],
},
{ name: 'Critical Explosion', variantKey: 'I' },
);
expect(result.errors).toEqual([]);
expect(result.program?.clauses[0].outcomes?.[0]).toMatchObject({
kind: 'direct_damage',
scope: 'target',
phase: 'after_event',
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
},
});
});
it('adapts assigned-ability activity predicates for kill and assist events', () => {
for (const eventKind of ['enemy_killed', 'assist'] as const) {
const result = adaptConditionalEffectProgram(
{
rule: rule(),
statePools: [],
clauses: [
clause({
trigger_id: null,
event_kind: eventKind,
parameters_json: '{}',
state_pool_id: null,
mutations_json: '[]',
conditions_json: JSON.stringify({
kind: 'compare',
left: {
source: 'event',
path: 'values.assignedAbilityActive',
},
operator: 'eq',
right: true,
}),
}),
],
outcomes: [outcome({ state_pool_id: null })],
triggers: [],
},
{ variantKey: 'I' },
);
expect(result).toMatchObject({ status: 'READY', errors: [] });
expect(result.program?.clauses[0]).toMatchObject({
when: eventKind,
if: {
kind: 'compare',
left: {
source: 'event',
path: 'values.assignedAbilityActive',
},
operator: 'eq',
right: true,
},
});
}
});
it('preserves outcome probability metadata', () => {
const result = adaptConditionalEffectProgram(
{
rule: rule(),
statePools: [],
clauses: [
clause({
trigger_id: null,
event_kind: 'shot_fired',
recipe_parameters_json: JSON.stringify({
procChancePercent: 10,
requiresAssignedAbilityActive: true,
}),
parameters_json: '{}',
state_pool_id: null,
mutations_json: '[]',
}),
],
outcomes: [
outcome({
state_pool_id: null,
application_kind: 'restore_ammo',
effect_scope: 'self',
stat_attribute_id: null,
resource_kind: 'ammo',
probability_percent: 29,
}),
],
triggers: [],
},
{ variantKey: 'I' },
);
expect(result).toMatchObject({ status: 'READY', errors: [] });
expect(result.program?.clauses[0].outcomes?.[0]).toMatchObject({
probabilityPercent: 29,
resource: 'ammo',
});
expect(result.program?.clauses[0]).toMatchObject({
procChancePercent: 10,
requiresAssignedAbilityActive: true,
});
});
it('loads continuous restoration delivery metadata', () => {
const result = adaptConditionalEffectProgram(
{
@@ -713,7 +850,7 @@ describe('conditional-effect persistence adapter', () => {
left: {
source: 'combat',
path: 'currentHealthPercent',
timing: 'current',
timing: 'pre_event',
},
operator: 'lt',
right: 25,
+55 -3
View File
@@ -54,6 +54,7 @@ export type ConditionalEffectClauseRow = {
event_kind?: DatabaseValue;
recipe_key?: DatabaseValue;
parameters_json: DatabaseValue;
recipe_parameters_json?: DatabaseValue;
conditions_json: DatabaseValue;
reducer_json: DatabaseValue;
weight_expression_json?: DatabaseValue;
@@ -85,6 +86,7 @@ export type ConditionalEffectOutcomeRow = {
delivery_mode?: DatabaseValue;
delivery_duration_seconds?: DatabaseValue;
targeting_json?: DatabaseValue;
probability_percent?: DatabaseValue;
value_scaling: DatabaseValue;
unit: DatabaseValue;
damage_basis: DatabaseValue;
@@ -205,14 +207,16 @@ function isValueReference(value: unknown) {
if (value.source === 'event' || value.source === 'equipment') {
return typeof value.path === 'string' && Boolean(value.path.trim());
}
if (value.source === 'assigned_ability') {
return value.path === 'active' || value.path === 'remainingDurationSeconds';
}
if (value.source === 'combat') {
return (
typeof value.path === 'string' &&
Boolean(value.path.trim()) &&
(value.timing === undefined ||
value.timing === 'pre_event' ||
value.timing === 'current') &&
!(value.path === 'currentHealthPercent' && value.timing === 'pre_event')
value.timing === 'current')
);
}
if (value.source === 'attribute') {
@@ -313,6 +317,19 @@ function isMutation(value: unknown): value is ClauseMutation {
isNumericExpression(value.value))
);
}
if (value.kind === 'extend_duration') {
return (
value.target === 'assigned_ability' && isNumericExpression(value.seconds)
);
}
if (value.kind === 'start_cooldown_on_max') {
return (
typeof value.stackPool === 'string' &&
Boolean(value.stackPool.trim()) &&
typeof value.cooldownPool === 'string' &&
Boolean(value.cooldownPool.trim())
);
}
return (
['add', 'set', 'consume', 'derive'].includes(value.kind) &&
typeof value.pool === 'string' &&
@@ -731,6 +748,17 @@ function parseOutcome(
if (delivery === 'instant' && durationSeconds !== null) {
errors.push(`${path} instant delivery cannot set a duration.`);
}
const probabilityPercent = number(row.probability_percent);
if (
row.probability_percent !== undefined &&
row.probability_percent !== null &&
row.probability_percent !== '' &&
(probabilityPercent === null ||
probabilityPercent < 0 ||
probabilityPercent > 100)
) {
errors.push(`${path} probability must be from 0 through 100 percent.`);
}
let targeting: MechanicOutcome['targeting'] | undefined;
if (
row.targeting_json !== null &&
@@ -766,6 +794,7 @@ function parseOutcome(
? { delivery, durationSeconds }
: {}),
...(targeting ? { targeting } : {}),
...(probabilityPercent === null ? {} : { probabilityPercent }),
...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id)
? { attributeId: text(row.stat_attribute_id) }
: {}),
@@ -903,7 +932,7 @@ export function adaptConditionalEffectProgram(
continue;
}
const parametersJson = parseJson(
row.parameters_json,
row.recipe_parameters_json ?? row.parameters_json,
`${path} parameters`,
errors,
);
@@ -976,6 +1005,25 @@ export function adaptConditionalEffectProgram(
}
}
const recipeKey = nullableText(row.recipe_key);
const procChancePercent = parametersJson.value.procChancePercent;
if (
procChancePercent !== undefined &&
(!finite(procChancePercent) ||
procChancePercent < 0 ||
procChancePercent > 100)
) {
errors.push(`${path} proc chance must be from 0 through 100 percent.`);
}
const requiresAssignedAbilityActive =
parametersJson.value.requiresAssignedAbilityActive;
if (
requiresAssignedAbilityActive !== undefined &&
typeof requiresAssignedAbilityActive !== 'boolean'
) {
errors.push(
`${path} assigned-ability activity requirement must be a boolean.`,
);
}
clauses.push({
id,
label: text(row.label) || text(row.clause_key) || undefined,
@@ -989,6 +1037,10 @@ export function adaptConditionalEffectProgram(
? {}
: { cooldownSeconds: number(row.cooldown_seconds) as number }),
...(weight === undefined ? {} : { weight }),
...(typeof procChancePercent === 'number' ? { procChancePercent } : {}),
...(typeof requiresAssignedAbilityActive === 'boolean'
? { requiresAssignedAbilityActive }
: {}),
mutations: mutationsJson.value,
...(clauseOutcomes.length ? { outcomes: clauseOutcomes } : {}),
});
+738 -18
View File
@@ -19,6 +19,276 @@ const custom = (
inputs,
});
describe('deterministic mechanic probabilities and assigned abilities', () => {
it('rolls outcome probability independently with a reproducible seed and preserves a refreshed window', () => {
const program: MechanicProgram = {
id: 'ammo-refill',
name: 'Ammo Refill',
pools: [
{
key: 'tactical-link-window',
label: 'Tactical Link window',
scope: 'agent',
initial: 0,
min: 0,
max: 1,
expiresAfterSeconds: 6,
},
],
clauses: [
{
id: 'start-window',
when: 'signature_ability_deployed',
reducer: { kind: 'none' },
mutations: [{ kind: 'set', pool: 'tactical-link-window', value: 1 }],
},
{
id: 'refresh-window',
when: 'enemy_killed',
reducer: { kind: 'none' },
mutations: [{ kind: 'set', pool: 'tactical-link-window', value: 1 }],
},
{
id: 'recover-ammo',
when: 'shot_fired',
if: {
kind: 'compare',
left: { source: 'pool', pool: 'tactical-link-window' },
operator: 'eq',
right: 1,
},
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
id: 'recover-one-ammo',
label: 'Recover one ammo',
phase: 'after_event',
kind: 'restoration',
scope: 'self',
basis: 'flat',
resource: 'ammo',
value: 1,
probabilityPercent: 29,
},
],
},
],
};
const events = [
{
kind: 'signature_ability_deployed' as const,
at: 0,
values: { assignedAbilityDurationSeconds: 6 },
},
{ kind: 'shot_fired' as const, at: 1 },
{ kind: 'shot_fired' as const, at: 2 },
{ kind: 'enemy_killed' as const, at: 5 },
{ kind: 'shot_fired' as const, at: 6 },
{ kind: 'shot_fired' as const, at: 11 },
];
const first = evaluateMechanicProgram(
program,
events,
{ mode: 'custom', encounterSeconds: 12, randomSeed: 1 },
{ maxHealth: 100, currentHealth: 100, maxAmmo: 20, currentAmmo: 5 },
);
const second = evaluateMechanicProgram(
program,
events,
{ mode: 'custom', encounterSeconds: 12, randomSeed: 1 },
{ maxHealth: 100, currentHealth: 100, maxAmmo: 20, currentAmmo: 5 },
);
expect(first.status).toBe('OK');
expect(first.randomSeed).toBe(1);
expect(first.outcomes.map((outcome) => outcome.at)).toEqual([2]);
expect(first.outcomes[0]).toMatchObject({
probabilityPercent: 29,
probabilityRoll: expect.any(Number),
appliedValue: 1,
});
expect(first.finalCombat?.currentAmmo).toBe(6);
expect(second.outcomes).toEqual(first.outcomes);
});
it('gates a proc before mutations, extends a live assigned ability, and starts cooldown on the eighth stack', () => {
const program: MechanicProgram = {
id: 'tactical-loop',
name: 'Tactical Loop',
pools: [
{
key: 'extension-stacks',
label: 'Extension stacks',
scope: 'agent',
initial: 7,
min: 0,
max: 8,
},
{
key: 'extension-cooldown',
label: 'Extension cooldown',
scope: 'agent',
initial: 0,
min: 0,
max: 1,
expiresAfterSeconds: 15,
},
],
clauses: [
{
id: 'extend-tactical-link',
when: 'skill_hit',
if: {
kind: 'compare',
left: { source: 'pool', pool: 'extension-cooldown' },
operator: 'eq',
right: 0,
},
requiresAssignedAbilityActive: true,
procChancePercent: 10,
reducer: { kind: 'none' },
mutations: [
{ kind: 'add', pool: 'extension-stacks', value: 1 },
{
kind: 'extend_duration',
target: 'assigned_ability',
seconds: 0.3,
},
{
kind: 'start_cooldown_on_max',
stackPool: 'extension-stacks',
cooldownPool: 'extension-cooldown',
},
],
},
],
};
const success = evaluateMechanicProgram(
program,
[
{
kind: 'signature_ability_deployed',
at: 0,
values: { assignedAbilityDurationSeconds: 6 },
},
{ kind: 'skill_hit', at: 5.9 },
{ kind: 'skill_hit', at: 6.1 },
],
{ mode: 'custom', encounterSeconds: 7, randomSeed: 7 },
);
const failed = evaluateMechanicProgram(
program,
[
{
kind: 'signature_ability_deployed',
at: 0,
values: { assignedAbilityDurationSeconds: 6 },
},
{ kind: 'skill_hit', at: 5.9 },
],
{ mode: 'custom', encounterSeconds: 7, randomSeed: 1 },
);
expect(success.status).toBe('OK');
expect(success.emissions).toHaveLength(1);
const extension = success.emissions[0].mutations.find(
(mutation) => mutation.kind === 'extend_duration',
);
expect(extension).toMatchObject({ kind: 'extend_duration' });
expect(extension?.before).toBeCloseTo(0.1);
expect(extension?.after).toBeCloseTo(0.4);
expect(success.emissions[0].mutations).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: 'start_cooldown_on_max',
pool: 'extension-cooldown',
after: 1,
}),
]),
);
expect(success.finalState).toEqual(
expect.arrayContaining([
expect.objectContaining({
pool: 'extension-stacks',
value: 8,
}),
expect.objectContaining({
pool: 'extension-cooldown',
value: 1,
expiresAt: 20.9,
}),
]),
);
expect(failed.emissions).toEqual([]);
});
it('does not extend an assigned ability at its exact expiry and rejects invalid chance data', () => {
const boundaryProgram: MechanicProgram = {
id: 'exact-expiry',
name: 'Exact expiry',
clauses: [
{
id: 'extend',
when: 'skill_hit',
requiresAssignedAbilityActive: true,
procChancePercent: 100,
reducer: { kind: 'none' },
mutations: [
{
kind: 'extend_duration',
target: 'assigned_ability',
seconds: 0.3,
},
],
},
],
};
expect(
evaluateMechanicProgram(
boundaryProgram,
[
{
kind: 'signature_ability_deployed',
at: 0,
values: { assignedAbilityDurationSeconds: 1 },
},
{ kind: 'skill_hit', at: 1 },
],
{ mode: 'custom', encounterSeconds: 2, randomSeed: 7 },
).emissions,
).toEqual([]);
const invalid = structuredClone(boundaryProgram);
invalid.clauses[0].procChancePercent = 101;
expect(validateMechanicProgram(invalid)).toMatchObject({
ok: false,
errors: [
expect.stringContaining('proc chance must be from 0 through 100'),
],
});
const invalidOutcome = structuredClone(boundaryProgram);
invalidOutcome.clauses[0].outcomes = [
{
id: 'bad-chance',
label: 'Bad chance',
phase: 'after_event',
kind: 'marker',
scope: 'self',
basis: 'flat',
value: 1,
probabilityPercent: -1,
},
];
expect(validateMechanicProgram(invalidOutcome)).toMatchObject({
ok: false,
errors: [
expect.stringContaining('probability must be from 0 through 100'),
],
});
});
});
describe('structured mechanic runtime', () => {
it('advertises the authoritative weapon-hit target and headshot fields', () => {
expect(MECHANIC_EVENT_REGISTRY.weapon_hit).toMatchObject({
@@ -32,6 +302,11 @@ describe('structured mechanic runtime', () => {
},
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' },
{ path: 'values.critical', label: 'Critical hit', type: 'boolean' },
{
path: 'values.aiming',
label: 'Aiming down sights',
type: 'boolean',
},
{
path: 'values.behindCover',
label: 'Agent behind cover',
@@ -305,6 +580,161 @@ describe('structured mechanic runtime', () => {
});
});
it('matches kills and assists only while the assigned ability is active', () => {
const weaponCriticalHitChance = 'weapon-critical-hit-chance';
const activeOutcome = {
id: 'tactical-link-critical-chance',
label: 'Weapon Critical Hit Chance',
phase: 'while_active' as const,
kind: 'attribute_modifier' as const,
scope: 'self' as const,
basis: 'flat' as const,
value: 10.2,
attributeId: weaponCriticalHitChance,
statePool: 'tactical-link-kill-bonus',
};
const activePredicate = {
kind: 'compare' as const,
left: {
source: 'event' as const,
path: 'values.assignedAbilityActive',
},
operator: 'eq' as const,
right: true,
};
const program: MechanicProgram = {
id: 'tactical-link-kill-bonus',
name: 'Tactical Link Kill Bonus',
pools: [
{
key: 'tactical-link-kill-bonus',
label: 'Tactical Link kill bonus',
scope: 'agent',
initial: 0,
min: 0,
max: 1,
expiresAfterSeconds: 10,
},
],
clauses: [
{
id: 'kill-during-tactical-link',
when: 'enemy_killed',
if: activePredicate,
reducer: { kind: 'none' },
mutations: [
{ kind: 'set', pool: 'tactical-link-kill-bonus', value: 1 },
],
outcomes: [activeOutcome],
},
{
id: 'assist-during-tactical-link',
when: 'assist',
if: activePredicate,
reducer: { kind: 'none' },
mutations: [
{ kind: 'set', pool: 'tactical-link-kill-bonus', value: 1 },
],
outcomes: [{ ...activeOutcome }],
},
],
};
expect(validateMechanicProgram(program)).toMatchObject({ ok: true });
const result = evaluateMechanicProgram(
program,
[
{
kind: 'enemy_killed',
at: 0,
values: { assignedAbilityActive: true },
},
{
kind: 'assist',
at: 4,
values: { assignedAbilityActive: false },
},
{ kind: 'enemy_killed', at: 6 },
{
kind: 'assist',
at: 10,
values: { assignedAbilityActive: true },
},
],
custom(19),
{
maxHealth: 1_000,
currentHealth: 1_000,
attributes: { [weaponCriticalHitChance]: 0 },
},
);
expect(result.status).toBe('OK');
expect(result.outcomes.map((outcome) => outcome.at)).toEqual([0, 10]);
expect(result.finalState).toContainEqual(
expect.objectContaining({
pool: 'tactical-link-kill-bonus',
value: 1,
expiresAt: 20,
}),
);
});
it('rejects invalid assigned-ability activity predicates and event values', () => {
const program: MechanicProgram = {
id: 'assigned-ability-activity',
name: 'Assigned ability activity',
clauses: [
{
id: 'kill',
when: 'enemy_killed',
if: {
kind: 'compare',
left: {
source: 'event',
path: 'values.assignedAbilityActive',
},
operator: 'eq',
right: true,
},
reducer: { kind: 'none' },
mutations: [],
},
],
};
const invalidPredicate = structuredClone(program);
invalidPredicate.clauses[0].if = {
kind: 'compare',
left: { source: 'event', path: 'values.assignedAbilityActive' },
operator: 'eq',
right: 'yes',
};
expect(validateMechanicProgram(invalidPredicate)).toMatchObject({
ok: false,
errors: [
expect.stringContaining('Assigned ability active with a boolean value'),
],
});
expect(
evaluateMechanicProgram(
program,
[
{
kind: 'enemy_killed',
at: 0,
values: { assignedAbilityActive: 'yes' },
} as unknown as MechanicEvent,
],
custom(1),
),
).toMatchObject({
status: 'INVALID',
errors: [
expect.stringContaining('Assigned ability active must be a boolean'),
],
});
});
it('executes imported talent trigger families as first-class events', () => {
const eventKinds: MechanicEventKind[] = [
'ally_downed',
@@ -2404,13 +2834,13 @@ describe('mechanic validation and pseudo-script', () => {
]);
});
it('rejects pre-event timing for the derived current Health percentage', () => {
const invalid: MechanicProgram = {
id: 'invalid-health-percent',
name: 'Invalid Health Percent',
it('evaluates pre-event timing for the derived current Health percentage', () => {
const program: MechanicProgram = {
id: 'pre-event-health-percent',
name: 'Pre-event Health Percent',
clauses: [
{
id: 'invalid',
id: 'full-health-before-event',
when: 'tick',
if: {
kind: 'compare',
@@ -2419,25 +2849,32 @@ describe('mechanic validation and pseudo-script', () => {
path: 'currentHealthPercent',
timing: 'pre_event',
},
operator: 'lt',
right: 50,
operator: 'gte',
right: 100,
},
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
id: 'matched',
label: 'Matched',
phase: 'on_event',
kind: 'marker',
scope: 'self',
basis: 'flat',
value: 1,
},
],
},
],
};
const result = validateMechanicProgram(invalid);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors).toEqual(
expect.arrayContaining([
expect.stringContaining(
'current Health percentage must use current timing',
),
]),
);
}
expect(validateMechanicProgram(program)).toMatchObject({ ok: true });
expect(
evaluateMechanicProgram(program, [{ kind: 'tick', at: 0 }], custom(1), {
maxHealth: 100,
currentHealth: 100,
}).outcomes.map((outcome) => outcome.outcomeId),
).toEqual(['matched']);
});
it('formats nested conditions and state operations deterministically', () => {
@@ -2550,4 +2987,287 @@ describe('mechanic validation and pseudo-script', () => {
expect(result.script).toContain('TO ALLIES WITHIN 40m');
expect(result.script).toContain('TO ENEMIES WITHIN 20m');
});
it('applies aimed critical-hit area damage once to each valid enemy at or inside the radius', () => {
const program: MechanicProgram = {
id: 'aimed-critical-explosion',
name: 'Aimed Critical Explosion',
clauses: [
{
id: 'aimed-critical-hit',
when: 'weapon_hit',
if: {
kind: 'all',
predicates: [
{
kind: 'compare',
left: { source: 'event', path: 'values.critical' },
operator: 'eq',
right: true,
},
{
kind: 'compare',
left: { source: 'event', path: 'values.aiming' },
operator: 'eq',
right: true,
},
],
},
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
id: 'explosion',
label: 'Explosion',
phase: 'after_event',
kind: 'direct_damage',
scope: 'target',
basis: 'flat',
value: {
op: 'multiply',
left: 20,
right: {
ref: { source: 'attribute', attributeId: 'firepower' },
},
},
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
},
probabilityPercent: 100,
},
],
},
],
};
const result = evaluateMechanicProgram(
program,
[
{
kind: 'weapon_hit',
at: 1,
targetId: 'enemy-b',
values: { critical: true, aiming: true },
areaTargetSample: {
center: 'event_target',
validTargets: [
{
entityId: 'enemy-c',
population: 'enemy',
distanceMeters: 5.001,
},
{ entityId: 'enemy-b', population: 'enemy', distanceMeters: 0 },
{ entityId: 'ally-a', population: 'ally', distanceMeters: 2 },
{ entityId: 'enemy-a', population: 'enemy', distanceMeters: 5 },
],
},
},
],
custom(5),
{ maxHealth: 1000, currentHealth: 1000, attributes: { firepower: 100 } },
);
expect(result.status).toBe('OK');
expect(result.outcomes.map((outcome) => outcome.targetId)).toEqual([
'enemy-a',
'enemy-b',
]);
expect(result.outcomes.map((outcome) => outcome.appliedValue)).toEqual([
2000, 2000,
]);
expect(
new Set(result.outcomes.map((outcome) => outcome.probabilityRoll)).size,
).toBe(1);
expect(result.outcomes[0].probabilityRoll).not.toBeNull();
expect(result.script).toContain(
'EXPLOSION CENTERED ON EVENT TARGET; APPLY TO VALID ENEMIES WITHIN 5m',
);
const maximum = evaluateMechanicProgram(
program,
[
{
kind: 'weapon_hit',
at: 1,
targetId: 'enemy-b',
values: { critical: true, aiming: true },
areaTargetSample: {
center: 'event_target',
validTargets: [
{ entityId: 'enemy-b', population: 'enemy', distanceMeters: 0 },
{ entityId: 'enemy-a', population: 'enemy', distanceMeters: 5 },
],
},
},
],
{ mode: 'maximum', encounterSeconds: 5 },
{ maxHealth: 1000, currentHealth: 1000, attributes: { firepower: 100 } },
);
expect(maximum.outcomes.map((outcome) => outcome.targetId)).toEqual([
'enemy-a',
'enemy-b',
]);
expect(
maximum.outcomes.every((outcome) => outcome.probabilityRoll === 0),
).toBe(true);
const skillProgram: MechanicProgram = {
...program,
id: 'skill-hit-explosion',
clauses: program.clauses.map((clause) => ({
...clause,
when: 'skill_hit',
if: undefined,
})),
};
expect(validateMechanicProgram(skillProgram)).toMatchObject({ ok: true });
expect(
evaluateMechanicProgram(
skillProgram,
[
{
kind: 'skill_hit',
at: 1,
targetId: 'enemy-b',
areaTargetSample: {
center: 'event_target',
validTargets: [
{ entityId: 'enemy-b', population: 'enemy', distanceMeters: 0 },
{ entityId: 'enemy-a', population: 'enemy', distanceMeters: 5 },
],
},
},
],
custom(5),
{
maxHealth: 1000,
currentHealth: 1000,
attributes: { firepower: 100 },
},
).outcomes.map((outcome) => outcome.targetId),
).toEqual(['enemy-a', 'enemy-b']);
});
it('requires authoritative area evidence only when an area outcome activates', () => {
const program: MechanicProgram = {
id: 'area-evidence',
name: 'Area Evidence',
clauses: [
{
id: 'critical-hit',
when: 'weapon_hit',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.critical' },
operator: 'eq',
right: true,
},
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
id: 'explosion',
label: 'Explosion',
phase: 'after_event',
kind: 'direct_damage',
scope: 'target',
basis: 'flat',
value: 1,
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
},
},
],
},
],
};
expect(
evaluateMechanicProgram(
program,
[
{
kind: 'weapon_hit',
at: 1,
targetId: 'enemy',
values: { critical: false },
},
],
custom(5),
),
).toMatchObject({ status: 'OK', outcomes: [] });
expect(
evaluateMechanicProgram(
program,
[
{
kind: 'weapon_hit',
at: 1,
targetId: 'enemy',
values: { critical: true },
},
],
custom(5),
),
).toMatchObject({
status: 'INVALID',
errors: [
expect.stringContaining('authoritative event-target area sample'),
],
});
});
it('rejects malformed and duplicate area-target samples', () => {
const result = evaluateMechanicProgram(
{ id: 'sample-validation', name: 'Sample Validation', clauses: [] },
[
{
kind: 'weapon_hit',
at: 1,
targetId: 'enemy',
areaTargetSample: {
center: 'event_target',
validTargets: [
{ entityId: 'enemy', population: 'enemy', distanceMeters: 0 },
{ entityId: 'enemy', population: 'enemy', distanceMeters: 1 },
],
},
},
],
custom(5),
);
expect(result).toMatchObject({
status: 'INVALID',
errors: [expect.stringContaining('duplicates entity "enemy"')],
});
const oversizedTargets = Array.from({ length: 257 }, (_, index) => ({
entityId: index === 0 ? 'enemy' : `enemy-${index}`,
population: 'enemy' as const,
distanceMeters: index === 0 ? 0 : 1,
}));
expect(
evaluateMechanicProgram(
{ id: 'sample-bound', name: 'Sample Bound', clauses: [] },
[
{
kind: 'weapon_hit',
at: 1,
targetId: 'enemy',
areaTargetSample: {
center: 'event_target',
validTargets: oversizedTargets,
},
},
],
custom(5),
),
).toMatchObject({
status: 'INVALID',
errors: [expect.stringContaining('cannot contain more than 256')],
});
});
});
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -206,7 +206,7 @@
<span><small>COMMUNITY NETWORK</small>JOIN SHD DISCORD ↗</span>
</a>
<nav class="footer-nav" aria-label="Support and legal">
<a href="/contact">CONTACT US</a><a href="/dmca">DMCA & COPYRIGHT</a><a href="/terms">TERMS & PRIVACY</a><a href="/terms#cookies">COOKIE POLICY</a><a href="/terms#retention">DATA RETENTION</a>
<a href="/contact">CONTACT US</a><a href="https://buymeacoffee.com/shd.build" target="_blank" rel="noreferrer">BUY ME A COFFEE ↗</a><a href="/dmca">DMCA & COPYRIGHT</a><a href="/terms">TERMS & PRIVACY</a><a href="/terms#cookies">COOKIE POLICY</a><a href="/terms#retention">DATA RETENTION</a>
</nav>
</div>
<p class="fan-disclaimer">UNOFFICIAL FAN SITE · NOT AFFILIATED WITH OR ENDORSED BY UBISOFT. TOM CLANCY’S THE DIVISION, THE DIVISION RESURGENCE, UBISOFT, AND RELATED INTELLECTUAL PROPERTY ARE OWNED BY UBISOFT AND/OR ITS LICENSORS.</p>
+44 -24
View File
@@ -23,7 +23,9 @@
let coverUploading = $state(false);
let initialized = $state(false);
let lastContent = '';
let saveSequence = 0;
let saveTimer: number | undefined;
let saveInFlight = false;
let saveQueued = false;
const groupedTags = $derived.by(() => {
const groups: Record<string, ComposerTag[]> = {};
for (const tag of data.tags as ComposerTag[]) (groups[tag.group] ??= []).push(tag);
@@ -35,6 +37,40 @@
coverImageId, coverAlt, coverCaption, version };
}
function scheduleSave() {
if (saveTimer !== undefined) window.clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => {
saveTimer = undefined;
void saveDraft();
}, 900);
}
async function saveDraft() {
if (saveInFlight || !saveQueued) return;
saveInFlight = true;
saveQueued = false;
const draft = { ...payload(), tagIds: [...selectedTags] };
try {
const response = await fetch('/api/articles/draft', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(draft)
});
const result = await response.json();
if (response.status === 409) {
saveState = 'conflict';
return;
}
if (!response.ok) throw new Error(result.message || 'Draft save failed.');
version = result.version;
saveState = saveQueued ? 'saving' : 'saved';
} catch (error) {
saveState = 'error';
toast.error('Draft not saved', error instanceof Error ? error.message : 'Try again.');
} finally {
saveInFlight = false;
if (saveQueued && saveState !== 'conflict') scheduleSave();
}
}
$effect(() => {
const content = JSON.stringify({ title, deck, document, selectedTags, channel, coverImageId, coverAlt, coverCaption });
if (!initialized) {
@@ -44,29 +80,13 @@
}
if (content === lastContent) return;
lastContent = content;
const sequence = ++saveSequence;
saveQueued = true;
saveState = 'saving';
const timer = window.setTimeout(async () => {
try {
const response = await fetch('/api/articles/draft', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload())
});
const result = await response.json();
if (sequence !== saveSequence) return;
if (response.status === 409) {
saveState = 'conflict';
return;
}
if (!response.ok) throw new Error(result.message || 'Draft save failed.');
version = result.version;
saveState = 'saved';
} catch (error) {
if (sequence !== saveSequence) return;
saveState = 'error';
toast.error('Draft not saved', error instanceof Error ? error.message : 'Try again.');
}
}, 900);
return () => window.clearTimeout(timer);
if (!saveInFlight) scheduleSave();
return () => {
if (saveTimer !== undefined) window.clearTimeout(saveTimer);
saveTimer = undefined;
};
});
function toggleTag(id: string) {
@@ -115,7 +135,7 @@
<div class="article-publication-note"><p>{visibility === 'private' ? 'Only you can open this draft.' : visibility === 'unlisted' ? 'Anyone with the direct URL can read it.' : 'Listed in discovery and visible to everyone.'}</p>{#if data.article.pendingRevisionId}<strong>MODERATION REVIEW PENDING</strong>{/if}</div>
<PendingForm action="?/publish" class="article-publish-form">
<input type="hidden" name="articleId" value={data.article.id}/><input type="hidden" name="title" value={title}/><input type="hidden" name="deck" value={deck}/><input type="hidden" name="document" value={document}/><input type="hidden" name="tagIds" value={JSON.stringify(selectedTags)}/><input type="hidden" name="channel" value={channel}/><input type="hidden" name="coverImageId" value={coverImageId}/><input type="hidden" name="coverAlt" value={coverAlt}/><input type="hidden" name="coverCaption" value={coverCaption}/><input type="hidden" name="version" value={version}/><input type="hidden" name="visibility" value={visibility}/>
<button class="primary">{visibility === 'private' ? 'SAVE AS PRIVATE' : data.article.kind === 'guide' ? 'SUBMIT FOR PUBLICATION' : 'PUBLISH ARTICLE'}</button>
<button class="primary" disabled={saveState === 'saving' || coverUploading}>{visibility === 'private' ? 'SAVE AS PRIVATE' : data.article.kind === 'guide' ? 'SUBMIT FOR PUBLICATION' : 'PUBLISH ARTICLE'}</button>
</PendingForm>
</section>
+12 -2
View File
@@ -24,6 +24,16 @@
function link(mark: ArticleMark) {
return mark.type === 'link' ? mark.attrs?.href ?? '#' : '#';
}
function orderedListStart(node: ArticleNode) {
const start = Number(node.attrs?.start ?? 1);
return Number.isInteger(start) && start > 1 ? start : undefined;
}
function orderedListType(node: ArticleNode): 'a' | 'A' | 'i' | 'I' | undefined {
const type = node.attrs?.type;
return ['a', 'A', 'i', 'I'].includes(String(type)) ? type as 'a' | 'A' | 'i' | 'I' : undefined;
}
</script>
{#snippet renderText(text: string, marks: ArticleMark[], index = 0)}
@@ -32,7 +42,7 @@
{#if mark.type === 'bold'}<strong>{@render renderText(text, marks, index + 1)}</strong>
{:else if mark.type === 'italic'}<em>{@render renderText(text, marks, index + 1)}</em>
{:else if mark.type === 'code'}<code>{@render renderText(text, marks, index + 1)}</code>
{:else}<a href={link(mark)} rel="nofollow ugc noopener noreferrer">{@render renderText(text, marks, index + 1)}</a>{/if}
{:else}<a href={link(mark)} title={mark.attrs?.title} rel="nofollow ugc noopener noreferrer">{@render renderText(text, marks, index + 1)}</a>{/if}
{/if}
{/snippet}
@@ -46,7 +56,7 @@
{:else if node.type === 'heading' && Number(node.attrs?.level) === 2}<h2>{@render children(node)}</h2>
{:else if node.type === 'heading'}<h3>{@render children(node)}</h3>
{:else if node.type === 'bulletList'}<ul>{@render children(node)}</ul>
{:else if node.type === 'orderedList'}<ol>{@render children(node)}</ol>
{:else if node.type === 'orderedList'}<ol start={orderedListStart(node)} type={orderedListType(node)}>{@render children(node)}</ol>
{:else if node.type === 'listItem'}<li>{@render children(node)}</li>
{:else if node.type === 'blockquote'}<blockquote>{@render children(node)}</blockquote>
{:else if node.type === 'horizontalRule'}<hr/>
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import ArticleContent from '$lib/components/ArticleContent.svelte';
import ArticleDiscussion from '$lib/components/ArticleDiscussion.svelte';
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import { untrack } from 'svelte';
let { data, form = null } = $props();
@@ -25,7 +25,7 @@
<header class="article-reader-head">
<div class="article-reader-channel"><span>{data.article.channel ?? (data.article.kind === 'guide' ? 'COMMUNITY GUIDE' : 'NEWS')}</span>{#if data.article.featured}<b>FEATURED</b>{/if}{#if data.article.recommended}<b>RECOMMENDED</b>{/if}</div>
<h1>{data.article.title}</h1><p>{data.article.deck}</p>
<div class="article-reader-byline">{#if data.article.source}<div class="article-source-identity"><strong>{data.article.source.name}</strong><span>{publicationMeta}</span></div>{:else}<AgentIdentityLink size="compact" linked={Boolean(data.article.authorPublicSlug)} agent={{id:data.article.ownerId,displayName:data.article.author,publicSlug:data.article.authorPublicSlug,avatarUrl:`/avatars/user/${data.article.ownerId}`}} meta={publicationMeta}/>{/if}<div>{#if data.article.kind === 'guide' && data.article.owner}<a class="secondary" href={`/guides/${encodeURIComponent(data.article.slug)}/edit`}>EDIT GUIDE</a>{:else if data.article.kind === 'news' && data.canManageNews && !data.article.source}<a class="secondary" href={`/news/${encodeURIComponent(data.article.slug)}/edit`}>OPEN NEWSROOM</a>{/if}</div></div>
<div class="article-reader-byline">{#if data.article.source}<div class="article-source-identity"><strong>{data.article.source.name}</strong><span>{publicationMeta}</span></div>{:else}<AgentIdentityLink size="compact" linked={Boolean(data.article.authorPublicSlug)} agent={{id:data.article.ownerId,displayName:data.article.author,publicSlug:data.article.authorPublicSlug,avatarUrl:`/avatars/user/${data.article.ownerId}`}} meta={publicationMeta}/>{/if}<div>{#if data.article.kind === 'guide' && data.article.owner}<a class="secondary" href={`/guides/${encodeURIComponent(data.article.slug)}/edit`}>EDIT GUIDE</a>{:else if data.article.kind === 'news' && data.canManageNews && !data.article.source}<a class="secondary" href={`/news/${encodeURIComponent(data.article.slug)}/edit`}>Edit Article</a>{/if}</div></div>
{#if data.article.source}<aside class="article-source-notice"><div><span>OFFICIAL SOURCE</span><strong>Originally published by {data.article.source.name}</strong></div><a href={data.article.source.url} target="_blank" rel="noopener noreferrer">READ ON UBISOFT</a></aside>{/if}
{#if data.tags.length}<nav class="article-reader-tags" aria-label="Article tags">{#each data.tags as tag}<a href={`/${path}?tag=${encodeURIComponent(tag.slug)}`}>{tag.name}</a>{/each}</nav>{/if}
</header>
+108
View File
@@ -0,0 +1,108 @@
<script lang="ts">
import { onMount } from 'svelte';
import { buildShareUrl, copyBuildLink, shareBuild } from '$lib/build-sharing';
import { toast } from '$lib/toast';
let {
title,
slugOrUrl,
visibility
}: {
title: string;
slugOrUrl?: string | null;
visibility: 'public' | 'unlisted' | 'private';
} = $props();
let pending = $state<'share' | 'copy' | null>(null);
let status = $state('');
let nativeShareAvailable = $state(false);
const canShare = $derived((visibility === 'public' || visibility === 'unlisted') && Boolean(slugOrUrl?.trim()));
onMount(() => {
nativeShareAvailable = typeof navigator.share === 'function';
});
function shareUrl() {
return buildShareUrl(slugOrUrl ?? '', window.location.origin);
}
async function handleShare() {
if (pending) return;
pending = 'share';
const result = await shareBuild(navigator, { title, url: shareUrl() });
pending = null;
if (result.state === 'shared') {
status = 'Build shared.';
toast.success('BUILD SHARED', 'The system share sheet accepted the Build link.');
} else if (result.state === 'cancelled') {
status = 'Sharing cancelled.';
toast.info('SHARING CANCELLED', 'No link was shared.');
} else if (result.state === 'unsupported') {
status = 'Native sharing is not supported on this device. Use Copy Link instead.';
toast.info('SHARE NOT SUPPORTED', 'Use COPY LINK to copy the Build URL.');
} else {
status = 'The Build could not be shared. Try Copy Link instead.';
toast.error('BUILD NOT SHARED', 'Try COPY LINK or try again.');
}
}
async function handleCopy() {
if (pending) return;
pending = 'copy';
const result = await copyBuildLink(navigator, shareUrl());
pending = null;
if (result.state === 'copied') {
status = 'Build link copied to the clipboard.';
toast.success('LINK COPIED', 'Paste the Build link wherever you want to share it.');
} else if (result.state === 'unsupported') {
status = 'Clipboard copying is not supported in this browser.';
toast.info('COPY NOT SUPPORTED', 'This browser cannot copy the Build link automatically.');
} else {
status = 'The Build link could not be copied.';
toast.error('LINK NOT COPIED', 'Check clipboard permissions and try again.');
}
}
</script>
<section class="build-share-control" aria-label="Share Build">
{#if canShare}
<div class="build-share-heading">
<div>
<span>BUILD UPLINK</span>
<strong>{visibility === 'unlisted' ? 'UNLISTED LINK READY' : 'PUBLIC LINK READY'}</strong>
</div>
<span class="build-share-signal" aria-hidden="true"></span>
</div>
<div class:single={!nativeShareAvailable} class="build-share-actions">
{#if nativeShareAvailable}
<button class="build-share-primary" type="button" onclick={handleShare} disabled={pending !== null} aria-busy={pending === 'share'}>
{pending === 'share' ? 'OPENING SHARE…' : 'SHARE BUILD →'}
</button>
{/if}
<button class:build-share-primary={!nativeShareAvailable} class:build-share-copy={nativeShareAvailable} type="button" onclick={handleCopy} disabled={pending !== null} aria-busy={pending === 'copy'}>
{pending === 'copy' ? 'COPYING…' : 'COPY LINK'}
</button>
</div>
<p class="build-share-discord"><img src="/discord-symbol.svg" alt="" aria-hidden="true" />Discord will render the Build preview when you post the copied link.</p>
{:else}
<div class="build-share-locked">
<span>{slugOrUrl?.trim() ? 'PRIVATE BUILD' : 'UNSAVED BUILD'}</span>
<strong>PUBLIC LINK UNAVAILABLE</strong>
<p>{slugOrUrl?.trim() ? 'Change visibility to Public or Unlisted before sharing this Build.' : 'Save this Build before creating a public or unlisted sharing link.'}</p>
</div>
{/if}
<p class="build-share-status" aria-live="polite" aria-atomic="true">{status}</p>
</section>
<style>
.build-share-control{display:grid;border:1px solid var(--shd-border-strong);background:#0d1011;font-family:var(--shd-font-sans)}
.build-share-heading{min-height:48px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 11px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}
.build-share-heading>div,.build-share-locked{display:grid;gap:3px}.build-share-heading span,.build-share-locked>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.9px}.build-share-heading strong,.build-share-locked strong{font-size:var(--shd-type-supporting);letter-spacing:.45px}.build-share-signal{width:8px;height:8px;border:1px solid var(--shd-orange);background:var(--shd-orange);box-shadow:0 0 0 3px #f6a8211c}
.build-share-actions{display:grid;grid-template-columns:minmax(0,1fr) minmax(120px,.45fr);gap:8px;padding:10px}.build-share-actions button{min-height:44px;padding:0 13px;border-radius:0;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;cursor:pointer;transition:var(--shd-transition)}.build-share-primary{border:1px solid var(--shd-orange);background:var(--shd-orange);color:#111415}.build-share-copy{border:1px solid var(--shd-border-strong);background:var(--shd-surface);color:var(--shd-text)}.build-share-primary:hover{background:var(--shd-orange-hover);border-color:var(--shd-orange-hover)}.build-share-copy:hover{border-color:var(--shd-text-muted);background:var(--shd-surface-active)}.build-share-actions button:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px}.build-share-actions button:active:not(:disabled){transform:translateY(1px);filter:brightness(.86)}.build-share-actions button:disabled{cursor:not-allowed;opacity:.48}
.build-share-actions.single{grid-template-columns:1fr}
.build-share-discord{display:flex;align-items:center;gap:7px;margin:0;padding:0 11px 10px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.35}.build-share-discord img{width:16px;height:13px;object-fit:contain;opacity:.72}.build-share-locked{padding:14px}.build-share-locked>span{color:var(--shd-danger)}.build-share-locked p{margin:2px 0 0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.build-share-status{min-height:0;margin:0;padding:0 11px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.build-share-status:not(:empty){min-height:32px;padding-top:7px;padding-bottom:7px;border-top:1px solid var(--shd-border-soft)}
@media(max-width:560px){.build-share-actions{grid-template-columns:1fr}.build-share-actions button{width:100%;min-height:44px}}
@media(prefers-reduced-motion:reduce){.build-share-actions button{transition:none}.build-share-actions button:active:not(:disabled){transform:none}}
</style>
+4 -4
View File
@@ -5,7 +5,7 @@
items,
value = $bindable(''),
label,
placeholder = 'Search available items…',
placeholder = 'Search…',
disabledIds = [],
onchange
}: {
@@ -27,19 +27,19 @@
<span>{label}</span>
<button type="button" class="builder-picker-current" aria-expanded={open} onclick={() => open = !open}>
<ItemIcon src={selected?.iconUrl} alt=""/>
<b>{selected?.name ?? 'SELECT ITEM'}</b><small>{selected?.category ?? (selected ? 'EQUIPPED' : 'UNASSIGNED')}</small><em>{open ? '×' : '+'}</em>
<b>{selected?.name ?? 'SELECT ITEM'}</b><small>{selected?.category ?? (selected ? 'EQUIPPED' : 'UNASSIGNED')}</small><em>{open ? '×' : '+'}</em>
</button>
{#if open}
<div class="builder-picker-panel">
<input type="search" bind:value={query} {placeholder} aria-label={placeholder}/>
<div>
{#if selected}<button type="button" class="builder-picker-clear" onclick={() => choose('')}><i>×</i><span><b>CLEAR SLOT</b><small>REMOVE CURRENT SELECTION</small></span><em>CLEAR</em></button>{/if}
{#if selected}<button type="button" class="builder-picker-clear" onclick={() => choose('')}><i>×</i><span><b>CLEAR</b><small>REMOVE SELECTION</small></span><em>CLEAR</em></button>{/if}
{#each results as item}
<button type="button" class:selected={item.id === value} disabled={disabledIds.includes(item.id)} onclick={() => choose(item.id)}>
<ItemIcon src={item.iconUrl} alt=""/>
<span><b>{item.name}</b><small>{item.category ?? 'AVAILABLE'}</small></span><em>{item.id === value ? 'EQUIPPED' : disabledIds.includes(item.id) ? 'CONFLICT' : 'SELECT'}</em>
</button>
{:else}<p>NO MATCHING ITEMS</p>{/each}
{:else}<p>NO MATCHES</p>{/each}
</div>
</div>
{/if}
+24 -15
View File
@@ -24,6 +24,8 @@
} = $props();
let query = $state('');
let open = $state(false);
const selected = $derived(items.find((item) => item.id === value));
const visible = $derived(items.filter((item) =>
!query.trim() || `${item.name} ${item.category ?? ''}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())
));
@@ -31,26 +33,33 @@
function choose(id: string) {
value = id;
onchange?.(id);
if (searchable) {
open = false;
query = '';
}
}
</script>
<fieldset class="builder-radio-grid" class:icon-only={iconOnly} style={`--builder-radio-columns:${Math.max(1, columns)}`}>
<legend>{label}</legend>
{#if searchable}
{#if searchable}<button type="button" class="builder-radio-toggle" aria-expanded={open} onclick={() => open = !open}><ItemIcon src={selected?.iconUrl} alt=""/><span><b>{selected?.name ?? 'SELECT OPTION'}</b><small>{selected?.category ?? 'NOT CONFIGURED'}</small></span><em>{open ? 'CLOSE' : 'CHANGE'}</em></button>{/if}
{#if searchable && open}
<input class="builder-radio-search" type="search" bind:value={query} placeholder="SEARCH AVAILABLE OPTIONS" aria-label={`Search ${label.toLocaleLowerCase()}`}/>
{/if}
<div>
{#each visible as item}
<label title={item.name}>
<input type="radio" {name} value={item.id} checked={value === item.id} {required} aria-label={item.name} onchange={() => choose(item.id)}/>
<span>
<ItemIcon src={item.iconUrl} alt=""/>
<b>{item.name}</b>
{#if item.category}<small>{item.category}</small>{/if}
</span>
</label>
{:else}
<p>NO MATCHING OPTIONS</p>
{/each}
</div>
{#if !searchable || open}
<div>
{#each visible as item}
<label title={item.name}>
<input type="radio" {name} value={item.id} checked={value === item.id} {required} aria-label={item.name} onchange={() => choose(item.id)}/>
<span>
<ItemIcon src={item.iconUrl} alt=""/>
<b>{item.name}</b>
{#if item.category}<small>{item.category}</small>{/if}
</span>
</label>
{:else}
<p>NO MATCHING OPTIONS</p>
{/each}
</div>
{/if}
</fieldset>
+43 -5
View File
@@ -6,6 +6,7 @@
import type { GameIconName } from '$lib/game-icons';
import type { DatabaseItem } from '$lib/database/items';
import MarkdownContent from './MarkdownContent.svelte';
import WeaponModSlotIcon from './WeaponModSlotIcon.svelte';
let {
item,
@@ -16,9 +17,21 @@
hideDiscussionUtility?: boolean;
anchor?: boolean;
} = $props();
let protocolLevel = $state(1);
let talentUpgrades = $state(0);
const number = (value: number) => Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, '');
const valueWithUnit = (value: number, unit: string) => `${number(value)}${unit ? (unit === '%' ? '%' : ` ${unit}`) : ''}`;
const protocolPrimaryValue = () => item.protocolPreview
? item.protocolPreview.primaryBaseValue * (1 + (protocolLevel - 1) / 49)
: 0;
const talentDescription = () => item.protocolPreview?.talentDescription?.replace(/\{(\d+)\}/g, (placeholder, raw) => {
const effect = item.protocolPreview?.talentEffects[Number(raw) - 1];
return effect ? valueWithUnit(effect.baseValue * (1 + talentUpgrades / 5), effect.unit) : placeholder;
});
</script>
<article id={anchor ? `database-item-${item.id}` : undefined} class="database-item-card" class:talent-card={item.variant === 'talent'}>
<article id={anchor ? `database-item-${item.id}` : undefined} class="database-item-card" class:talent-card={item.variant === 'talent'} class:protocol-quality-standard={item.topBorderTone === 'standard'} class:protocol-quality-specialized={item.topBorderTone === 'specialized'} class:protocol-quality-superior={item.topBorderTone === 'superior'} class:protocol-quality-highend={item.topBorderTone === 'highend'}>
<header>
{#if item.renderUrl}<img class="database-item-render" src={item.renderUrl} alt="" loading="lazy"/>{/if}
<div class="database-item-icon">
@@ -31,13 +44,38 @@
{#if item.headerBadges?.length || item.headerBadge || item.headerMetaIcon}
<div class="database-header-status">
{#if item.headerMetaIcon}<GameIcon name={item.headerMetaIcon}/>{/if}
{#each item.headerBadges ?? [] as badge}<span class="database-header-badge">{badge}</span>{/each}
{#if item.headerBadge}<span class="database-header-badge">{item.headerBadge}</span>{/if}
{#each item.headerBadges ?? [] as badge}<span class="database-header-badge" class:exotic={badge === 'EXOTIC'}>{badge}</span>{/each}
{#if item.headerBadge}<span class="database-header-badge" class:protocol-badge-standard={item.headerBadgeTone === 'standard'} class:protocol-badge-specialized={item.headerBadgeTone === 'specialized'} class:protocol-badge-superior={item.headerBadgeTone === 'superior'} class:protocol-badge-highend={item.headerBadgeTone === 'highend'}>{item.headerBadge}</span>{/if}
</div>
{/if}
</header>
{#if item.badges?.length}<div class="database-item-badges">{#each item.badges as badge}<span class={badge.tone ?? ''}>{badge.label}</span>{/each}</div>{/if}
<DatabaseFacts facts={item.facts} layout={item.factLayout}/>
{#if item.protocolPreview}
<section class="database-protocol-preview">
<div class="database-protocol-primary"><span>{item.protocolPreview.primaryStat}</span><b>{Number.isFinite(protocolPrimaryValue()) ? valueWithUnit(protocolPrimaryValue(), item.protocolPreview.primaryUnit) : 'NOT CONFIGURED'}</b></div>
{#if item.protocolPreview.talentDescription}
<div class="database-protocol-talent"><p>{talentDescription()}</p></div>
{/if}
<div class="database-protocol-controls"><label class="database-protocol-level"><span>LVL {protocolLevel}</span><input type="range" min="1" max="50" step="1" bind:value={protocolLevel} aria-label={`${item.name} level`}/></label>{#if item.protocolPreview.talentDescription}<div class="database-protocol-diamonds" role="group" aria-label={`${item.name} talent upgrades`}>{#each [1, 2, 3, 4, 5] as upgrade}<button type="button" class:active={talentUpgrades >= upgrade} aria-label={`Set talent upgrades to ${upgrade}`} aria-pressed={talentUpgrades === upgrade} onclick={() => talentUpgrades = talentUpgrades === upgrade ? 0 : upgrade}>◆</button>{/each}</div>{/if}</div>
</section>
{:else}
<DatabaseFacts facts={item.facts} layout={item.factLayout}/>
{/if}
{#if item.weaponModSlots}
<div class="database-weapon-mod-slots">
<div><span>MOD SLOTS</span></div>
{#if item.weaponModSlots.length}
<ul aria-label={`${item.name} Weapon Mod slots`}>
{#each item.weaponModSlots as modSlot}
<li class:upgrade-slot={modSlot.unlockedAtTierOne} class:has-slot-icon={Boolean(modSlot.iconUrl)} title={`${modSlot.slot}${modSlot.unlockedAtTierOne ? ' - Unlocked when this weapon upgrades to Tier 1' : ''}`}>
{#if modSlot.iconUrl}<WeaponModSlotIcon slot={modSlot.slot} iconUrl={modSlot.iconUrl}/><span class="sr-only">{modSlot.slot}</span>{:else}<span>{modSlot.slot}</span>{/if}
{#if modSlot.unlockedAtTierOne}<small>T1+</small>{/if}
</li>
{/each}
</ul>
{:else}<p>MOD SLOT DATA NOT CONFIGURED</p>{/if}
</div>
{/if}
{#if item.lines?.length}
<div class="database-item-lines">{#each item.lines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>
{/if}
@@ -53,7 +91,7 @@
<section><span>TIER I</span><p>{item.description ?? 'Description is not available.'}</p></section>
<section><span>TIER II</span><p>{item.secondaryDescription ?? 'Description is not available.'}</p></section>
</div>
{:else}
{:else if !item.protocolPreview}
{#if item.description}<p class:data-description={item.descriptionStyle === 'data'}>{item.description}</p>{/if}
{#if item.secondaryDescription}<p class="secondary-description" class:data-description={item.descriptionStyle === 'data'}>{item.secondaryDescription}</p>{/if}
{/if}
+7 -1
View File
@@ -75,7 +75,11 @@
}
function paste(event: ClipboardEvent) {
const file = Array.from(event.clipboardData?.files ?? []).find((item) => item.type.startsWith('image/'));
const file =
Array.from(event.clipboardData?.items ?? [])
.find((item) => item.kind === 'file' && item.type.startsWith('image/'))
?.getAsFile() ??
Array.from(event.clipboardData?.files ?? []).find((item) => item.type.startsWith('image/'));
if (file) {
event.preventDefault();
setFile(file);
@@ -107,10 +111,12 @@
});
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -- focus is required as a scoped native paste target -->
<div
class="image-drop-input"
class:dragging
role="group"
tabindex="0"
aria-label="Drop an image here or paste one from the clipboard"
ondragenter={(event) => { event.preventDefault(); dragging = true; }}
ondragover={(event) => event.preventDefault()}
+1 -1
View File
@@ -14,4 +14,4 @@
const resolvedAlt = $derived(src || !alt ? alt : `${alt} — icon awaiting contribution`);
</script>
<img src={src || MISSING_ITEM_ICON_URL} alt={resolvedAlt} class:missing-item-icon={!src} class={className}/>
<img src={src || MISSING_ITEM_ICON_URL} alt={resolvedAlt} class:missing-item-icon={!src} class={`item-icon ${className}`.trim()}/>
@@ -0,0 +1,125 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import {
MOD_QUALITIES,
MOD_SECONDARY_CAPACITY,
MOD_SECONDARY_QUALITIES,
type ModSecondaryRange
} from '$lib/mods/secondary-pool';
let {
kind,
pool,
stats,
error,
saved = false,
resultToken = ''
}: {
kind: 'Weapon Mod' | 'Gear Mod';
pool: ModSecondaryRange[];
stats: Array<{ id: string; name: string; unit: string }>;
error?: string;
saved?: boolean;
resultToken?: string;
} = $props();
const poolId = $derived(kind === 'Weapon Mod' ? 'weapon-mod-secondary-pool' : 'gear-mod-secondary-pool');
const action = '?/saveSecondaryPool';
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
const blankSecondary = (): ModSecondaryRange => ({
statId: '',
values: Object.fromEntries(
MOD_SECONDARY_QUALITIES.map((quality) => [quality, {
level1Min: 0,
level1Max: 0,
level20Min: 0,
level20Max: 0
}])
) as ModSecondaryRange['values']
});
let editing = $state(false);
let draft = $state<ModSecondaryRange[]>([]);
let handledResultToken = $state('');
function openEditor() {
draft = pool.length ? structuredClone(pool) : [blankSecondary(), blankSecondary()];
editing = true;
}
function addSecondary() {
if (draft.length < 50) draft = [...draft, blankSecondary()];
}
function removeSecondary(index: number) {
if (draft.length > 2) draft = draft.filter((_, itemIndex) => itemIndex !== index);
}
$effect(() => {
if (saved && resultToken && resultToken !== handledResultToken) {
handledResultToken = resultToken;
editing = false;
}
});
</script>
<section class="mod-secondary-pool-registry">
<div class="selection-head">
<div>
<span>SHARED {kind.toUpperCase()} SECONDARY POOL</span>
<small>ONE CANONICAL POOL FOR EVERY REGULAR {kind.toUpperCase()}</small>
</div>
<button type="button" class="row-action" onclick={openEditor}>EDIT SHARED POOL →</button>
</div>
<div class="mod-secondary-capacity">
{#each MOD_QUALITIES as quality}
<span><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><strong>{MOD_SECONDARY_CAPACITY[quality]}</strong><small>SECONDARY {MOD_SECONDARY_CAPACITY[quality] === 1 ? 'ROLL' : 'ROLLS'}</small></span>
{/each}
<span class="pool-total"><b>ATTRIBUTE POOL</b><strong>{pool.length}</strong><small>SHARED OPTIONS</small></span>
</div>
</section>
{#if editing}
<div class="dashboard-editor mod-secondary-pool-editor">
<div class="editor-title">
<div><p class="eyebrow">SHARED CONFIGURATION</p><h3>{kind.toUpperCase()} SECONDARY ATTRIBUTES</h3></div>
<button aria-label={`Close ${kind} secondary pool editor`} onclick={() => editing = false}>×</button>
</div>
<PendingForm {action}>
<input type="hidden" name="id" value={poolId}/>
<input type="hidden" name="name" value={`Shared ${kind} Secondary Pool`}/>
<input type="hidden" name="secondaryRanges" value={JSON.stringify(draft)}/>
<p class="console-note">Standard quality has no secondary roll. Specialized selects one option; Superior and High-end select two distinct options. Ranges scale linearly from Level 1 to Level 20.</p>
<div class="selection-head pool-editor-head">
<div><span>AVAILABLE ATTRIBUTES</span><small>SPECIALIZED · SUPERIOR · HIGH-END LEVEL ENDPOINTS</small></div>
<button type="button" class="row-action" onclick={addSecondary} disabled={draft.length >= 50}>ADD ATTRIBUTE +</button>
</div>
<div class="mod-secondary-pool-options">
{#each draft as range, index}
<div class="mod-secondary-pool-option">
<strong>{String(index + 1).padStart(2, '0')}</strong>
<SearchableAssignment
items={stats}
mode="single"
compact
subjectLabel="POSSIBLE ATTRIBUTE"
searchPlaceholder="Search attributes…"
excludedIds={draft.filter((_, itemIndex) => itemIndex !== index).map((item) => item.statId)}
bind:value={range.statId}
/>
{#each MOD_SECONDARY_QUALITIES as quality}
<div class="mod-secondary-quality-range">
<b class={qualityClass(quality)}>{quality.toUpperCase()}</b>
<label><span>LEVEL 1</span><div><input type="number" step="any" bind:value={range.values[quality].level1Min} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 1 minimum`} required/><i>–</i><input type="number" step="any" bind:value={range.values[quality].level1Max} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 1 maximum`} required/></div></label>
<label><span>LEVEL 20</span><div><input type="number" step="any" bind:value={range.values[quality].level20Min} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 20 minimum`} required/><i>–</i><input type="number" step="any" bind:value={range.values[quality].level20Max} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 20 maximum`} required/></div></label>
</div>
{/each}
<button type="button" aria-label={`Remove shared secondary attribute option ${index + 1}`} onclick={() => removeSecondary(index)} disabled={draft.length <= 2}>×</button>
</div>
{/each}
</div>
{#if error}<p class="error">{error}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => editing = false}>CANCEL</button><button class="primary">SAVE SHARED POOL →</button></div>
</PendingForm>
</div>
{/if}
@@ -29,8 +29,6 @@
gear_max: 'gearMax',
weapon_min: 'weaponMin',
weapon_max: 'weaponMax',
skill_chip_min: 'chipMin',
skill_chip_max: 'chipMax',
cap_value: 'capValue'
};
return proposed[names[key] ?? key];
@@ -46,6 +44,8 @@
);
const specializationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const skillChipRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const currentSharedSkillChipProfiles = $derived((Array.isArray(currentSnapshot) ? currentSnapshot : []) as any[]);
const proposedSharedSkillChipProfiles = $derived((jsonValue(proposed.profiles) ?? []) as any[]);
const skillChipFields = $derived([
{ label: 'NAME', current: skillChipRecord?.name, proposed: proposed.name },
{ label: 'SPECIALIZATION ABILITY', current: skillChipRecord?.specialization_ability_id, proposed: proposed.abilityId },
@@ -78,6 +78,7 @@
{ label: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId },
{ label: 'DURATION · SECONDS', current: specializationRecord?.duration_seconds, proposed: proposed.durationSeconds },
{ label: 'COOLDOWN · SECONDS', current: specializationRecord?.cooldown_seconds, proposed: proposed.cooldownSeconds },
{ label: 'SHARED SKILL CHIP ABILITY', current: (currentSnapshot?.skillChipLinks as any[])?.[0]?.ability_id === specializationRecord?.id ? (currentSnapshot?.skillChipLinks as any[])?.[0]?.compatible_ability_id : (currentSnapshot?.skillChipLinks as any[])?.[0]?.ability_id, proposed: proposed.skillChipCompatibleAbilityId },
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
]
: action === 'saveCombatEffect'
@@ -256,7 +257,7 @@
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('current', field[0]))}</strong></div>
{/each}
{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
{#each [['gear','GEAR'],['weapon','WEAPON']] as source}
<div class="preview-field range"><span>{source[1]} RANGE · OPTIONAL</span><div><strong>{shown(attributeValue('current', `${source[0]}_min`))}</strong><i>TO</i><strong>{shown(attributeValue('current', `${source[0]}_max`))}</strong></div></div>
{/each}
<div class="preview-field"><span>OPTIONAL CAP</span><strong>{shown(attributeValue('current', 'cap_value'))}</strong></div>
@@ -273,13 +274,24 @@
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('proposed', field[0]))}</strong></div>
{/each}
{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
{#each [['gear','GEAR'],['weapon','WEAPON']] as source}
<div class="preview-field range"><span>{source[1]} RANGE · OPTIONAL</span><div><strong>{shown(attributeValue('proposed', `${source[0]}_min`))}</strong><i>TO</i><strong>{shown(attributeValue('proposed', `${source[0]}_max`))}</strong></div></div>
{/each}
<div class="preview-field"><span>OPTIONAL CAP</span><strong>{shown(attributeValue('proposed', 'cap_value'))}</strong></div>
</div>
</article>
</div>
{:else if module === 'skillchips' && ['savePrimaryProfile','saveSecondaryPool'].includes(action)}
<div class="proposal-compare-grid">
<article class="proposal-editor-preview before">
<header><span>CURRENT LIVE · SHARED {action === 'savePrimaryProfile' ? 'PRIMARY PROFILE' : 'SECONDARY POOL'}</span><b>BEFORE</b></header>
<div class="proposal-structured-list">{#each currentSharedSkillChipProfiles as profile}<p>{profile.statId} · {Object.entries(profile.values ?? {}).map(([quality, range]: [string, any]) => `${quality.toUpperCase()} ${shown(range.min)}–${shown(range.max)}`).join(' · ')}</p>{:else}<p>SHARED PROFILE NOT CONFIGURED</p>{/each}</div>
</article>
<article class="proposal-editor-preview after">
<header><span>PROPOSED · SHARED {action === 'savePrimaryProfile' ? 'PRIMARY PROFILE' : 'SECONDARY POOL'}</span><b>AFTER</b></header>
<div class="proposal-structured-list">{#each proposedSharedSkillChipProfiles as profile}<p>{profile.statId} · {Object.entries(profile.values ?? {}).map(([quality, range]: [string, any]) => `${quality.toUpperCase()} ${shown(range.min)}–${shown(range.max)}`).join(' · ')}</p>{/each}</div>
</article>
</div>
{:else if module === 'skillchips' && action === 'save'}
<div class="proposal-compare-grid">
<article class="proposal-editor-preview before">
@@ -287,7 +299,7 @@
{#if skillChipRecord}
<div class="proposal-field-summary">
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.current)}</strong></div>{/each}
<div><span>ROLL POOLS</span><strong>{(currentSnapshot?.primaryOptions as any[])?.length ?? 0} PRIMARY · {(currentSnapshot?.secondaryOptions as any[])?.length ?? 0} SECONDARY</strong></div>
<div><span>3-PIECE TALENT</span><strong>{currentSnapshot?.talent ? 'ASSIGNED' : 'NOT ASSIGNED'}</strong></div>
</div>
<div class="proposal-structured-list">
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
@@ -301,7 +313,7 @@
<header><span>PROPOSED · SKILL CHIP</span><b>AFTER</b></header>
<div class="proposal-field-summary">
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.proposed)}</strong></div>{/each}
<div><span>ROLL POOLS</span><strong>{Array.isArray(proposed.primaryStatIds) ? proposed.primaryStatIds.length : Number(Boolean(proposed.primaryStatIds))} PRIMARY · {Array.isArray(proposed.secondaryStatIds) ? proposed.secondaryStatIds.length : Number(Boolean(proposed.secondaryStatIds))} SECONDARY</strong></div>
<div><span>3-PIECE TALENT</span><strong>{proposed.hasTalent === '1' ? 'ASSIGNED' : 'NOT ASSIGNED'}</strong></div>
</div>
<div class="proposal-structured-list">
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
@@ -0,0 +1,128 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import {
SKILL_CHIP_QUALITIES,
SKILL_CHIP_SECONDARY_CAPACITY,
SKILL_CHIP_SECONDARY_QUALITIES,
type SkillChipProfile
} from '$lib/skill-chips/profiles';
let {
primaryProfiles,
primaryProfileBase,
secondaryPool,
stats,
primaryError,
secondaryError,
primarySaved = false,
secondarySaved = false,
resultToken = ''
}: {
primaryProfiles: SkillChipProfile[];
primaryProfileBase: SkillChipProfile[];
secondaryPool: SkillChipProfile[];
stats: Array<{ id: string; name: string; unit: string }>;
primaryError?: string;
secondaryError?: string;
primarySaved?: boolean;
secondarySaved?: boolean;
resultToken?: string;
} = $props();
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
const blankValues = (qualities: readonly string[]) => Object.fromEntries(
qualities.map((quality) => [quality, { min: '', max: '' }])
);
const hydrated = (profile: SkillChipProfile, qualities: readonly string[]) => ({
statId: profile.statId,
values: Object.fromEntries(qualities.map((quality) => [quality, {
min: profile.values[quality]?.min ?? '',
max: profile.values[quality]?.max ?? ''
}]))
});
const blankSecondary = () => ({ statId: '', values: blankValues(SKILL_CHIP_SECONDARY_QUALITIES) });
let primaryEditing = $state(false);
let secondaryEditing = $state(false);
let primaryDraft = $state<any[]>([]);
let secondaryDraft = $state<any[]>([]);
let handledPrimaryToken = $state('');
let handledSecondaryToken = $state('');
function openPrimary() {
primaryDraft = primaryProfiles.map((profile) => hydrated(profile, SKILL_CHIP_QUALITIES));
secondaryEditing = false;
primaryEditing = true;
}
function openSecondary() {
const loaded: any[] = secondaryPool.map((profile) =>
hydrated(profile, SKILL_CHIP_SECONDARY_QUALITIES)
);
while (loaded.length < 2) loaded.push(blankSecondary());
secondaryDraft = loaded;
primaryEditing = false;
secondaryEditing = true;
}
$effect(() => {
if (primarySaved && resultToken && resultToken !== handledPrimaryToken) {
handledPrimaryToken = resultToken;
primaryEditing = false;
}
if (secondarySaved && resultToken && resultToken !== handledSecondaryToken) {
handledSecondaryToken = resultToken;
secondaryEditing = false;
}
});
</script>
<div class="skill-chip-shared-config">
<section class="mod-secondary-pool-registry">
<div class="selection-head"><div><span>SHARED PRIMARY PROFILE</span><small>FOUR ABILITY-SCOPED STATS · ONE RANGE PER QUALITY</small></div><button type="button" class="row-action" onclick={openPrimary}>EDIT PRIMARY RANGES →</button></div>
<div class="mod-secondary-capacity">
{#each SKILL_CHIP_QUALITIES as quality}<span><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><strong>{primaryProfiles.filter((profile) => profile.values[quality]).length}</strong><small>OF 4 CONFIGURED</small></span>{/each}
<span class="pool-total"><b>CALCULATION</b><strong>—</strong><small>NOT YET DEFINED</small></span>
</div>
</section>
<section class="mod-secondary-pool-registry">
<div class="selection-head"><div><span>SHARED SECONDARY POOL</span><small>ONE CANONICAL ATTRIBUTE POOL FOR EVERY SKILL CHIP</small></div><button type="button" class="row-action" onclick={openSecondary}>EDIT SHARED POOL →</button></div>
<div class="mod-secondary-capacity">
{#each SKILL_CHIP_QUALITIES as quality}<span><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><strong>{SKILL_CHIP_SECONDARY_CAPACITY[quality]}</strong><small>SECONDARY {SKILL_CHIP_SECONDARY_CAPACITY[quality] === 1 ? 'ROLL' : 'ROLLS'}</small></span>{/each}
<span class="pool-total"><b>ATTRIBUTE POOL</b><strong>{secondaryPool.length}</strong><small>SHARED OPTIONS</small></span>
</div>
</section>
</div>
{#if primaryEditing}
<div class="dashboard-editor skill-chip-profile-editor">
<div class="editor-title"><div><p class="eyebrow">SHARED CONFIGURATION</p><h3>SKILL CHIP PRIMARY RANGES</h3></div><button aria-label="Close primary profile editor" onclick={() => primaryEditing = false}>×</button></div>
<PendingForm action="?/savePrimaryProfile">
<input type="hidden" name="id" value="skill-chip-primary-profile"/><input type="hidden" name="name" value="Shared Skill Chip Primary Profile"/><input type="hidden" name="baseProfiles" value={JSON.stringify(primaryProfileBase)}/><input type="hidden" name="profiles" value={JSON.stringify(primaryDraft)}/>
<p class="console-note">Every Skill Chip rolls one of these four ability-scoped primary stats. Configure the direct minimum and maximum for each quality. Current combat calculations do not apply these values.</p>
<div class="skill-chip-profile-options fixed">
{#each primaryDraft as profile}
<div class="skill-chip-profile-option"><div><strong>{stats.find((item) => item.id === profile.statId)?.name ?? 'UNKNOWN ATTRIBUTE'}</strong><small>{stats.find((item) => item.id === profile.statId)?.unit || 'FLAT'} · ABILITY SCOPED</small></div>{#each SKILL_CHIP_QUALITIES as quality}<label><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><span><input type="number" min="0" step="any" bind:value={profile.values[quality].min} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? 'attribute'} minimum`} required/><i>–</i><input type="number" min="0" step="any" bind:value={profile.values[quality].max} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? 'attribute'} maximum`} required/></span></label>{/each}</div>
{/each}
</div>
{#if primaryError}<p class="error">{primaryError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => primaryEditing = false}>CANCEL</button><button class="primary">SAVE PRIMARY PROFILE →</button></div>
</PendingForm>
</div>
{/if}
{#if secondaryEditing}
<div class="dashboard-editor skill-chip-profile-editor">
<div class="editor-title"><div><p class="eyebrow">SHARED CONFIGURATION</p><h3>SKILL CHIP SECONDARY POOL</h3></div><button aria-label="Close secondary pool editor" onclick={() => secondaryEditing = false}>×</button></div>
<PendingForm action="?/saveSecondaryPool">
<input type="hidden" name="id" value="skill-chip-secondary-pool"/><input type="hidden" name="name" value="Shared Skill Chip Secondary Pool"/><input type="hidden" name="baseProfiles" value={JSON.stringify(secondaryPool)}/><input type="hidden" name="profiles" value={JSON.stringify(secondaryDraft)}/>
<p class="console-note">Standard has no secondary roll. Specialized selects one option; Superior and High-end select two distinct options. Skill Chips have no item level, so each quality uses one direct range.</p>
<div class="selection-head pool-editor-head"><div><span>AVAILABLE ATTRIBUTES</span><small>SPECIALIZED · SUPERIOR · HIGH-END RANGES</small></div><button type="button" class="row-action" onclick={() => secondaryDraft = [...secondaryDraft, blankSecondary()]} disabled={secondaryDraft.length >= 50}>ADD ATTRIBUTE +</button></div>
<div class="skill-chip-profile-options">
{#each secondaryDraft as profile, index}
<div class="skill-chip-profile-option selectable"><strong>{String(index + 1).padStart(2, '0')}</strong><SearchableAssignment items={stats} mode="single" compact subjectLabel="POSSIBLE ATTRIBUTE" searchPlaceholder="Search attributes…" excludedIds={secondaryDraft.filter((_, itemIndex) => itemIndex !== index).map((item) => item.statId)} bind:value={profile.statId}/>{#each SKILL_CHIP_SECONDARY_QUALITIES as quality}<label><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><span><input type="number" min="0" step="any" bind:value={profile.values[quality].min} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? `option ${index + 1}`} minimum`} required/><i>–</i><input type="number" min="0" step="any" bind:value={profile.values[quality].max} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? `option ${index + 1}`} maximum`} required/></span></label>{/each}<button type="button" aria-label={`Remove secondary option ${index + 1}`} onclick={() => secondaryDraft = secondaryDraft.filter((_, itemIndex) => itemIndex !== index)} disabled={secondaryDraft.length <= 2}>×</button></div>
{/each}
</div>
{#if secondaryError}<p class="error">{secondaryError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => secondaryEditing = false}>CANCEL</button><button class="primary">SAVE SHARED POOL →</button></div>
</PendingForm>
</div>
{/if}
@@ -0,0 +1,29 @@
<script lang="ts">
let {
slot,
iconUrl,
decorative = true,
size = 24
}: { slot: string; iconUrl?: string | null; decorative?: boolean; size?: number } = $props();
</script>
{#if iconUrl}
<img
class="weapon-mod-slot-icon"
src={iconUrl}
alt={decorative ? '' : `${slot} Weapon Mod slot`}
loading="lazy"
width={size}
height={size}
style={`--weapon-mod-slot-icon-size: ${size}px`}
/>
{/if}
<style>
img {
width: var(--weapon-mod-slot-icon-size);
height: var(--weapon-mod-slot-icon-size);
flex: 0 0 var(--weapon-mod-slot-icon-size);
object-fit: contain;
}
</style>
@@ -0,0 +1,212 @@
<script lang="ts">
import IconUploadField from '$lib/components/IconUploadField.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import WeaponModSlotIcon from '$lib/components/WeaponModSlotIcon.svelte';
type SlotIcon = {
id: string;
name: string;
iconUrl: string;
updatedAt: string;
};
let {
slots,
error = '',
saved = false,
resultToken = ''
}: {
slots: SlotIcon[];
error?: string;
saved?: boolean;
resultToken?: string;
} = $props();
let editing = $state<SlotIcon | null>(null);
let handledResultToken = $state('');
$effect(() => {
if (saved && resultToken && resultToken !== handledResultToken) {
handledResultToken = resultToken;
editing = null;
}
});
</script>
<section class="slot-icon-registry" aria-labelledby="slot-icon-registry-title">
<div class="selection-head">
<div>
<span id="slot-icon-registry-title">SHARED MOD SLOT ICONS</span>
<small>ONE CANONICAL ICON FOR EACH WEAPON MOD SLOT</small>
</div>
<b>{slots.every((slot) => slot.iconUrl) ? 'READY' : 'CONFIG REQUIRED'}</b>
</div>
<div class="slot-icon-grid">
{#each slots as slot}
<button
type="button"
class:configured={Boolean(slot.iconUrl)}
aria-pressed={editing?.id === slot.id}
onclick={() => editing = slot}
>
<span class="slot-icon-preview">
{#if slot.iconUrl}
<WeaponModSlotIcon slot={slot.name} iconUrl={slot.iconUrl}/>
{:else}
<i aria-hidden="true">?</i>
{/if}
</span>
<span><strong>{slot.name}</strong><small>{slot.iconUrl ? 'ICON REGISTERED' : 'ICON REQUIRED'}</small></span>
<b>EDIT</b>
</button>
{/each}
</div>
</section>
{#if editing}
<section class="dashboard-editor slot-icon-editor" aria-labelledby="slot-icon-editor-title">
<div class="editor-title">
<div><p class="eyebrow">SHARED MOD SLOT ARTWORK</p><h3 id="slot-icon-editor-title">{editing.name.toUpperCase()}</h3></div>
<button aria-label="Close slot icon editor" onclick={() => editing = null}>×</button>
</div>
<PendingForm action="?/saveSlotIcon" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="name" value={`${editing.name} Slot`}/>
<input type="hidden" name="slotType" value={editing.name}/>
<input type="hidden" name="baseUpdatedAt" value={editing.updatedAt}/>
<div class="slot-icon-editor-body">
<div class="slot-icon-editor-identity">
<span class="slot-icon-preview large">
{#if editing.iconUrl}
<WeaponModSlotIcon slot={editing.name} iconUrl={editing.iconUrl} size={56}/>
{:else}
<i aria-hidden="true">?</i>
{/if}
</span>
<div><small>CANONICAL SLOT</small><strong>{editing.name}</strong><p>Weapon cards use this artwork anywhere the slot is available.</p></div>
</div>
<IconUploadField currentUrl={editing.iconUrl} label={`${editing.name.toUpperCase()} SLOT ICON`} resetToken={`${editing.id}:${editing.updatedAt}`}/>
</div>
{#if error}<p class="error" role="alert">{error}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE SLOT ICON →</button></div>
</PendingForm>
</section>
{/if}
<style>
.slot-icon-registry {
margin: 10px 0;
padding: 10px;
border: 1px solid var(--shd-border);
border-left: 2px solid var(--shd-info);
background: #0d1011;
}
.slot-icon-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 7px;
margin-top: 8px;
}
.slot-icon-grid > button {
min-height: 62px;
display: grid;
grid-template-columns: 38px minmax(0, 1fr) auto;
align-items: center;
gap: 9px;
padding: 8px;
border: 1px solid var(--shd-border);
background: #101415;
color: var(--shd-text);
text-align: left;
cursor: pointer;
}
.slot-icon-grid > button:hover,
.slot-icon-grid > button:focus-visible,
.slot-icon-grid > button[aria-pressed='true'] {
border-color: var(--shd-orange);
background: #191712;
}
.slot-icon-grid > button:focus-visible {
outline: 2px solid var(--shd-orange);
outline-offset: 2px;
}
.slot-icon-grid > button > span:nth-child(2) {
min-width: 0;
display: grid;
gap: 3px;
}
.slot-icon-grid strong,
.slot-icon-grid small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.slot-icon-grid strong {
font-size: var(--shd-type-label);
letter-spacing: .04em;
}
.slot-icon-grid small,
.slot-icon-grid > button > b {
color: var(--shd-text-muted);
font-size: var(--shd-type-micro);
letter-spacing: .07em;
}
.slot-icon-grid > button.configured small,
.slot-icon-grid > button.configured > b {
color: var(--shd-info);
}
.slot-icon-preview {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border: 1px solid #313839;
background: #090b0c;
}
.slot-icon-preview i {
color: #586164;
font-size: 20px;
font-style: normal;
font-weight: 900;
}
.slot-icon-editor {
border-top-color: var(--shd-info);
}
.slot-icon-editor-body {
display: grid;
grid-template-columns: minmax(260px, .7fr) minmax(360px, 1.3fr);
gap: 12px;
}
.slot-icon-editor-identity {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid var(--shd-border);
background: #0d1011;
}
.slot-icon-preview.large {
width: 76px;
height: 76px;
flex: 0 0 76px;
}
.slot-icon-editor-identity > div {
display: grid;
gap: 5px;
}
.slot-icon-editor-identity small,
.slot-icon-editor-identity p {
color: var(--shd-text-muted);
font-size: var(--shd-type-telemetry);
}
.slot-icon-editor-identity p {
margin: 0;
}
@media (max-width: 900px) {
.slot-icon-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.slot-icon-editor-body { grid-template-columns: 1fr; }
}
@media (max-width: 480px) {
.slot-icon-grid { grid-template-columns: 1fr; }
}
</style>
+31 -31
View File
@@ -37,20 +37,20 @@
&& Math.floor(Number(active.split(':')[1]) / 3) === Number(cell.id.split(':')[1]));
const issueLabel = (issue: string) => {
if (/not configured|required/i.test(issue)) return 'ACTION REQUIRED';
if (/incomplete/i.test(issue)) return 'DATA INCOMPLETE';
return 'CHECK CONFIGURATION';
if (/not configured|required|select|choose/i.test(issue)) return 'REQUIRED';
if (/incomplete/i.test(issue)) return 'INCOMPLETE';
return 'CHECK';
};
</script>
<section class="loadout-matrix" aria-label="Loadout matrix">
<header>
<span>LOADOUT MATRIX</span>
<b class:ready={configured}>{configured ? 'COMBAT READY' : 'CONFIGURATION INCOMPLETE'}</b>
<span>LOADOUT</span>
<b class:ready={configured}>{configured ? 'COMPLETE' : 'INCOMPLETE'}</b>
</header>
<div class="matrix-section doctrine-section">
<small class="section-label">DOCTRINE · ROOT CHOICE</small>
<small class="section-label">SPECIALIZATION FOCUS</small>
<button
type="button"
class="doctrine-cell"
@@ -60,27 +60,15 @@
onclick={() => onselect(doctrine.id)}
>
<ItemIcon src={doctrine.iconUrl} alt="" className="matrix-icon" />
<span><small>{doctrine.label}</small><strong>{doctrine.name}</strong><em>{doctrine.summary}</em>{#if doctrine.issue}<mark title={doctrine.issue} aria-hidden="true">{issueLabel(doctrine.issue)}</mark><span class="sr-only">{doctrine.issue}</span>{/if}</span>
<span><small>{doctrine.label}</small><strong>{doctrine.name}</strong><em class:visible={selected(doctrine)}>{doctrine.summary}</em>{#if doctrine.issue}<mark title={doctrine.issue} aria-hidden="true">{issueLabel(doctrine.issue)}</mark><span class="sr-only">{doctrine.issue}</span>{/if}</span>
<i aria-hidden="true"></i>
</button>
<div class="supply-line"><span>SUPPLIES 3 ABILITIES</span></div>
<div class="supply-line"><span>ABILITIES</span></div>
<div class="ability-cells">
{#each abilities as cell}
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
</button>
{/each}
</div>
</div>
<div class="matrix-section">
<small class="section-label">WEAPONS · PRIMARY + SECONDARY</small>
<div class="equipment-cells">
{#each weapons as cell}
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<span><small>{cell.label}</small><strong>{cell.name}</strong><em class:visible={selected(cell)}>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<i aria-hidden="true"></i>
</button>
{/each}
@@ -88,12 +76,25 @@
</div>
<div class="matrix-section">
<small class="section-label">GEAR · 6 POSITIONS</small>
<small class="section-label">WEAPONS</small>
<div class="equipment-cells">
{#each weapons as cell}
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em class:visible={selected(cell)}>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<i aria-hidden="true"></i>
</button>
{/each}
</div>
</div>
<div class="matrix-section">
<small class="section-label">GEAR</small>
<div class="equipment-cells gear-cells">
{#each gear as cell}
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<span><small>{cell.label}</small><strong>{cell.name}</strong><em class:visible={selected(cell)}>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<i aria-hidden="true"></i>
</button>
{/each}
@@ -101,10 +102,10 @@
</div>
<div class="matrix-section protocol-section">
<small class="section-label">AGENT SYSTEM · ONE OS PROTOCOL</small>
<small class="section-label">OS PROTOCOL</small>
<button type="button" class:active={selected(protocol)} class:needs-attention={Boolean(protocol.issue)} aria-pressed={selected(protocol)} onclick={() => onselect(protocol.id)}>
<ItemIcon src={protocol.iconUrl} alt="" className="matrix-icon" />
<span><small>{protocol.label}</small><strong>{protocol.name}</strong><em>{protocol.summary}</em>{#if protocol.issue}<mark title={protocol.issue} aria-hidden="true">{issueLabel(protocol.issue)}</mark><span class="sr-only">{protocol.issue}</span>{/if}</span>
<span><small>{protocol.label}</small><strong>{protocol.name}</strong><em class:visible={selected(protocol)}>{protocol.summary}</em>{#if protocol.issue}<mark title={protocol.issue} aria-hidden="true">{issueLabel(protocol.issue)}</mark><span class="sr-only">{protocol.issue}</span>{/if}</span>
<i aria-hidden="true"></i>
</button>
</div>
@@ -112,21 +113,20 @@
<style>
.loadout-matrix{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}
.loadout-matrix>header{min-height:40px;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 10px;border-bottom:1px solid var(--shd-border);background:#0c0f10;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.08em}
.loadout-matrix>header{height:auto;min-height:40px;position:static;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 10px;border-bottom:1px solid var(--shd-border);background:#0c0f10;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.08em}
.loadout-matrix>header b{color:var(--shd-warning);font-size:9px;text-align:right}.loadout-matrix>header b.ready{color:var(--shd-success)}
.matrix-section{padding:5px;border-bottom:1px solid var(--shd-border)}.matrix-section:last-child{border-bottom:0}
.section-label{display:block;padding:2px 3px 5px;color:var(--shd-text-dim);font-size:9px;font-weight:900;letter-spacing:.09em}
.doctrine-cell,.equipment-cells button,.ability-cells button,.protocol-section>button{min-width:0;display:grid;grid-template-columns:34px minmax(0,1fr) 3px;align-items:center;gap:7px;padding:7px;border:1px solid var(--shd-border-soft);background:var(--shd-builder-canvas);color:var(--shd-text);text-align:left}
.doctrine-cell{width:100%;min-height:76px;grid-template-columns:42px minmax(0,1fr) 3px}.doctrine-cell :global(.matrix-icon){width:40px;height:40px}
:global(.matrix-icon){width:30px;height:30px;object-fit:contain}
button>span{min-width:0;display:grid;gap:2px}button small,button em{overflow:hidden;color:var(--shd-text-dim);font-size:9px;font-style:normal;letter-spacing:.04em;text-overflow:ellipsis;white-space:nowrap}button strong{overflow:hidden;font-size:11px;text-overflow:ellipsis;white-space:nowrap}button>i{align-self:stretch;background:var(--quality-high-end)}
button>span{min-width:0;display:grid;gap:2px}button small,button em{overflow:hidden;color:var(--shd-text-dim);font-size:9px;font-style:normal;letter-spacing:.04em;text-overflow:ellipsis;white-space:nowrap}button em{display:none}button em.visible{display:block}button strong{overflow:hidden;font-size:11px;text-overflow:ellipsis;white-space:nowrap}button>i{align-self:stretch;background:var(--quality-high-end)}
button:hover,button:focus-visible{border-color:var(--shd-border-strong);background:var(--shd-surface-active);outline:none}button.active{border-color:var(--shd-builder-selection-border);background:var(--shd-builder-selection);box-shadow:inset 2px 0 var(--shd-orange)}
button.needs-attention{border-bottom-color:color-mix(in srgb,var(--shd-danger) 68%,var(--shd-border-soft))}button.needs-attention>i{background:var(--shd-danger)}button mark{width:fit-content;max-width:100%;overflow:hidden;margin-top:2px;padding:2px 4px;border:1px solid color-mix(in srgb,var(--shd-danger) 58%,transparent);background:#1d1212;color:var(--shd-danger);font-size:8px;font-weight:900;letter-spacing:.07em;line-height:1.2;text-overflow:ellipsis;text-transform:uppercase;white-space:nowrap}
.supply-line{height:22px;display:grid;place-items:center;background:linear-gradient(90deg,transparent 49.7%,var(--shd-orange) 49.7% 50.3%,transparent 50.3%)}.supply-line span{padding:0 6px;background:var(--shd-surface);color:var(--shd-orange);font-size:8px;font-weight:900;letter-spacing:.08em}
.ability-cells{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:3px}.ability-cells button{min-height:68px;grid-template-columns:25px minmax(0,1fr);padding:6px}.ability-cells :global(.matrix-icon){width:24px;height:24px}
.ability-cells{display:grid;grid-template-columns:1fr;gap:3px}.ability-cells button{min-height:48px;grid-template-columns:25px minmax(0,1fr) 3px;padding:6px}.ability-cells :global(.matrix-icon){width:24px;height:24px}
.equipment-cells{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:3px}.equipment-cells button{min-height:69px}.protocol-section>button{width:100%;min-height:68px}
button:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px}
@media(max-width:1240px){.ability-cells{grid-template-columns:1fr}.ability-cells button{min-height:48px}}
@media(max-width:800px){.ability-cells{grid-template-columns:repeat(3,minmax(0,1fr))}}
@media(max-width:520px){.ability-cells{grid-template-columns:1fr}.equipment-cells{grid-template-columns:1fr 1fr}}
@media(max-width:800px) and (min-width:651px){.ability-cells{grid-template-columns:repeat(3,minmax(0,1fr))}.ability-cells button{min-height:68px}}
@media(max-width:520px){.ability-cells{grid-template-columns:1fr}.equipment-cells{grid-template-columns:1fr 1fr}.gear-cells{grid-template-columns:1fr}}
</style>
@@ -0,0 +1,112 @@
<script lang="ts">
import GameIcon from '$lib/components/GameIcon.svelte';
import type { GameIconName } from '$lib/game-icons';
import type { LoadoutSummary } from '$lib/builder/loadout-summary';
let {
summary,
attributes,
talents = [],
headingId,
layout = 'rail'
}: {
summary: LoadoutSummary;
attributes: any[];
talents?: any[];
headingId: string;
layout?: 'rail' | 'page';
} = $props();
const attribute = (id: string) => attributes.find((item) => item.id === id);
const talent = (id: string) => talents.find((item) => item.id === id);
const unitValue = (value: unknown, statId: string) => {
const numeric = Number(value);
const rendered = Number.isInteger(numeric)
? numeric.toLocaleString()
: numeric.toFixed(2).replace(/\.?0+$/, '');
const unit = attribute(statId)?.unit ?? '';
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
};
const talentCooldown = (id: string) => {
const value = talent(id)?.cooldown_seconds;
if (value === null || value === undefined || value === '') return '';
return `Cooldown: ${value} ${Number(value) === 1 ? 'second' : 'seconds'}`;
};
</script>
<section class:page={layout === 'page'} class="loadout-known-stats" aria-labelledby={headingId}>
<div class="summary-head">
<div>
<p class="eyebrow"><span></span> KNOWN STATS</p>
<h2 id={headingId}>Agent stats</h2>
</div>
</div>
<div class="summary-core">
{#each ['Firepower', 'Toughness', 'Engineering'] as name}
{@const stat = summary.stats.find((entry) => attribute(entry.id)?.name === name)}
<div>
<GameIcon name={name.toLowerCase() as GameIconName} decorative />
<span>{name}<b>{stat ? unitValue(stat.value, stat.id) : '0'}</b></span>
</div>
{/each}
</div>
<div class="summary-scroll">
<div class="summary-section">
<header><span>ATTRIBUTES</span><b>{summary.stats.length}</b></header>
{#each summary.stats as stat}
<div class="summary-stat">
<span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span>
<b>{unitValue(stat.value, stat.id)}</b>
</div>
{:else}
<p>Equip items to see known Attributes.</p>
{/each}
</div>
<div class="summary-section">
<header><span>SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>
{#each summary.activeBonuses as bonus}
<div class="summary-bonus">
<span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span>
<em>+{unitValue(bonus.value, bonus.statId)} {attribute(bonus.statId)?.name}</em>
</div>
{:else}
<p>No active bonuses.</p>
{/each}
</div>
<div class="summary-section">
<header><span>TALENTS</span><b>{summary.equippedTalents.length}</b></header>
{#each summary.equippedTalents as equipped}
<div class="summary-talent">
<details open={layout === 'page'}>
<summary><b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b><small>{equipped.source}{equipped.tierLabel ? ` · ${equipped.tierLabel}` : ''}</small></summary>
<p>{equipped.description}</p>
{#if equipped.id && talentCooldown(equipped.id)}<i class="talent-cooldown">{talentCooldown(equipped.id)}</i>{/if}
</details>
</div>
{:else}
<p>No Talents equipped.</p>
{/each}
</div>
</div>
<p class="known-stats-note">Known values only. Advanced effects are planned.</p>
</section>
<style>
.loadout-known-stats{background:var(--shd-surface)}
.summary-talent{grid-template-columns:1fr;cursor:default}
.summary-talent details,.summary-talent summary{min-width:0}
.summary-talent summary{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;align-items:center;cursor:pointer;list-style:none}
.summary-talent summary::-webkit-details-marker{display:none}
.summary-talent summary:after{content:'+';grid-column:2;grid-row:1/3;color:var(--shd-orange);font-size:14px}
.summary-talent details[open] summary:after{content:'-'}
.summary-talent summary b,.summary-talent summary small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.summary-talent summary small{grid-column:1;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}
.summary-talent details>p{margin:8px 0 0;color:var(--shd-text-muted);font:var(--shd-type-telemetry)/1.45 var(--shd-font-editorial)}
.summary-talent summary:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:3px}
.known-stats-note{margin:0;padding:11px;border-top:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.45}
.page{border:1px solid var(--shd-border);border-top:2px solid var(--shd-success)}
.page .summary-scroll{max-height:none;display:grid;grid-template-columns:repeat(3,minmax(0,1fr))}
.page .summary-section{border-left:1px solid var(--shd-border)}
.page .summary-section:first-child{border-left:0}
@media(max-width:900px){.page .summary-scroll{grid-template-columns:1fr}.page .summary-section{border-left:0;border-top:1px solid var(--shd-border)}.page .summary-section:first-child{border-top:0}}
</style>
@@ -0,0 +1,47 @@
<script lang="ts">
let {
headingId,
variant = 'full',
headingLevel = 'h3'
}: {
headingId: string;
variant?: 'full' | 'compact';
headingLevel?: 'h2' | 'h3';
} = $props();
const capabilities = [
{ label: 'WEAPON OUTPUT', detail: 'Hits and firing cycles' },
{ label: 'SKILL SIMULATION', detail: 'Timing and target caps' },
{ label: 'ENCOUNTER MODEL', detail: 'Duration and rotations' },
{ label: 'CONDITIONAL EFFECTS', detail: 'Triggers and uptime' }
];
</script>
<section class:compact={variant === 'compact'} class="planned-combat-model" aria-labelledby={headingId}>
<header>
<div><span>COMBAT MODEL</span>{#if headingLevel === 'h2'}<h2 id={headingId}>DPS calculations</h2>{:else}<h3 id={headingId}>DPS calculations</h3>{/if}</div>
<b>PLANNED</b>
</header>
{#if variant === 'full'}
<p>Known stats are live. Damage projections are planned.</p>
{/if}
<div class="planned-capabilities" aria-label="Planned combat-model capabilities">
{#each capabilities as capability}
<div><span><strong>{capability.label}</strong><small>{capability.detail}</small></span><em>PLANNED</em></div>
{/each}
</div>
</section>
<style>
.planned-combat-model{min-width:0;display:grid;grid-template-columns:minmax(0,1fr);gap:var(--shd-gap-md);padding:var(--shd-gap-md);overflow:hidden;border:1px solid var(--shd-model-border);border-top:2px solid var(--shd-info);background:var(--shd-model-surface)}
.planned-combat-model>*{min-width:0}
header{display:flex;align-items:start;justify-content:space-between;gap:var(--shd-gap-md);padding:0}
header div{display:grid;gap:3px}header span{color:var(--shd-info);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}h2,h3{margin:0;color:var(--shd-text);font-size:var(--shd-type-body);letter-spacing:.7px;text-transform:uppercase}header b{padding:5px 7px;border:1px solid var(--shd-info);color:var(--shd-info);font-size:var(--shd-type-telemetry);letter-spacing:.8px;white-space:nowrap}
p{max-width:72ch;margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.5}
.planned-capabilities{width:100%;min-width:0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));border:1px solid var(--shd-model-border)}
.planned-capabilities>div{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm);min-width:0;padding:9px;border-top:1px solid var(--shd-model-border);border-left:1px solid var(--shd-model-border)}
.planned-capabilities>div:nth-child(-n+2){border-top:0}.planned-capabilities>div:nth-child(odd){border-left:0}
.planned-capabilities span{display:grid;gap:3px;min-width:0}.planned-capabilities strong,.planned-capabilities em{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.planned-capabilities small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.35}.planned-capabilities em{color:var(--shd-text-dim);font-style:normal;white-space:nowrap}
.compact{gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)}.compact .planned-capabilities{grid-template-columns:minmax(0,1fr)}.compact .planned-capabilities>div{border-left:0}.compact .planned-capabilities>div:nth-child(2){border-top:1px solid var(--shd-model-border)}
@media(max-width:620px){header{display:grid}.planned-capabilities{grid-template-columns:1fr}.planned-capabilities>div{min-height:44px;border-left:0}.planned-capabilities>div:nth-child(2){border-top:1px solid var(--shd-model-border)}}
</style>
@@ -0,0 +1,61 @@
<script lang="ts">
import ItemIcon from '$lib/components/ItemIcon.svelte';
import type { TalentTier } from '$lib/builder/loadout';
let {
label = 'TALENT TIER',
name,
talent,
value = $bindable(1),
onchange
}: {
label?: string;
name: string;
talent: { name: string; iconUrl?: string; tiers?: Array<{ tier: string }> };
value?: TalentTier;
onchange?: (tier: TalentTier) => void;
} = $props();
const available = (tier: TalentTier) => talent.tiers?.some((entry) => entry.tier === (tier === 2 ? 'II' : 'I')) ?? false;
const choose = (tier: TalentTier) => {
value = tier;
onchange?.(tier);
};
</script>
<fieldset class="talent-tier-choice">
<legend>{label}</legend>
<div>
{#each [1, 2] as tier}
<label class:tier-two={tier === 2}>
<input type="radio" {name} value={tier} checked={value === tier} disabled={!available(tier as TalentTier)} onchange={() => choose(tier as TalentTier)}/>
<span>
<ItemIcon src={talent.iconUrl} alt="" className="talent-tier-icon"/>
<small>TIER {tier === 2 ? 'II' : 'I'}</small>
<b>T{tier}</b>
<em>{available(tier as TalentTier) ? value === tier ? 'SELECTED' : 'SELECT' : 'UNAVAILABLE'}</em>
</span>
</label>
{/each}
</div>
</fieldset>
<style>
.talent-tier-choice{min-width:0;margin:8px 0 0;padding:0;border:0}
.talent-tier-choice legend{margin-bottom:5px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.07em}
.talent-tier-choice>div{display:grid;grid-template-columns:1fr 1fr;gap:5px}
.talent-tier-choice label{min-width:0;cursor:pointer}
.talent-tier-choice input{position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}
.talent-tier-choice span{min-height:64px;display:grid;grid-template-columns:42px minmax(0,1fr) auto;grid-template-rows:auto auto;align-items:center;gap:1px 8px;padding:7px;border:1px solid var(--shd-border);background:var(--shd-builder-canvas);color:var(--shd-text);transition:var(--shd-transition)}
.talent-tier-choice :global(.talent-tier-icon){grid-row:1/3;width:40px;height:40px;object-fit:contain}
.talent-tier-choice .tier-two :global(.talent-tier-icon){filter:sepia(.3) saturate(1.45) hue-rotate(335deg) brightness(1.12)}
.talent-tier-choice small{align-self:end;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.06em}
.talent-tier-choice b{grid-column:3;grid-row:1/3;color:var(--shd-text-muted);font-size:18px}
.talent-tier-choice em{align-self:start;color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-style:normal;font-weight:900}
.talent-tier-choice label:hover span{border-color:var(--shd-border-strong);background:var(--shd-surface-active)}
.talent-tier-choice input:checked+span{border-color:var(--shd-builder-selection-border);background:var(--shd-builder-selection);box-shadow:inset 0 -2px var(--shd-orange)}
.talent-tier-choice input:checked+span b,.talent-tier-choice input:checked+span em{color:var(--shd-orange)}
.talent-tier-choice input:focus-visible+span{outline:2px solid var(--shd-focus-ring);outline-offset:2px}
.talent-tier-choice input:disabled+span{opacity:.38;cursor:not-allowed;filter:saturate(.35)}
@media(max-width:430px){.talent-tier-choice span{grid-template-columns:36px minmax(0,1fr);}.talent-tier-choice :global(.talent-tier-icon){width:34px;height:34px}.talent-tier-choice b{display:none}}
</style>
@@ -0,0 +1,97 @@
<script lang="ts">
import type { WeaponBaseline } from '$lib/builder/weapon-baseline';
type WeaponBaselineEntry = {
slot: 'PRIMARY' | 'SECONDARY';
id: string;
name: string;
damage: number | null;
rpm: number | null;
magazine: number | null;
reloadSpeed: number | null;
baseline: WeaponBaseline;
};
let { entries, headingId }: { entries: WeaponBaselineEntry[]; headingId: string } = $props();
const numberValue = (value: number | null, suffix = '') =>
value === null || !Number.isFinite(value) || value <= 0 ? 'NOT AVAILABLE' : `${value.toLocaleString(undefined, { maximumFractionDigits: 2 })}${suffix}`;
const metricValue = (metric: { value: number | null; missing: string[] }) => numberValue(metric.value, ' /s');
</script>
<section class="weapon-baseline" aria-labelledby={headingId}>
<header class="baseline-head">
<div>
<p class="eyebrow"><span></span> WEAPON OUTPUT / CATALOG BASELINE</p>
<h2 id={headingId}>Weapon output</h2>
</div>
<strong>EXPERIMENTAL</strong>
</header>
<p class="baseline-intro">Strict evidence-only throughput from the selected catalog weapon and selected damage.</p>
<div class="baseline-results" aria-live="polite" aria-atomic="false">
{#each entries as entry}
<article class="weapon-result" aria-labelledby={`${headingId}-${entry.slot.toLowerCase()}`}>
<header class="weapon-result-head">
<div>
<span>{entry.slot}</span>
<h3 id={`${headingId}-${entry.slot.toLowerCase()}`}>{entry.name}</h3>
</div>
<code>{entry.id || 'NO CATALOG ID'}</code>
</header>
<div class="baseline-inputs" aria-label={`${entry.slot} baseline inputs`}>
<span><small>SELECTED DAMAGE</small><b>{numberValue(entry.baseline.selectedDamage.value)}</b></span>
<span><small>RPM</small><b>{numberValue(entry.rpm)}</b></span>
<span><small>MAGAZINE</small><b>{numberValue(entry.magazine, ' SHOTS')}</b></span>
<span><small>RELOAD</small><b>{numberValue(entry.reloadSpeed, ' SEC')}</b></span>
</div>
<div class="baseline-metrics">
{#each [
{ label: 'RATED BODY-HIT THROUGHPUT', metric: entry.baseline.ratedBodyHitThroughput },
{ label: 'MAGAZINE-CYCLE BODY-HIT THROUGHPUT', metric: entry.baseline.magazineCycleBodyHitThroughput }
] as output}
<div class:unavailable={output.metric.value === null}>
<span>{output.label}<small>PER SECOND</small></span>
<strong>{metricValue(output.metric)}</strong>
{#if output.metric.value === null}
<p class="missing-label">MISSING FIELDS</p>
<ul>{#each output.metric.missing as field}<li>{field}</li>{/each}</ul>
{/if}
</div>
{/each}
</div>
</article>
{/each}
</div>
<div class="baseline-contract">
<p><b>FORMULAS</b> Selected damage × (RPM ÷ 60)</p>
<p>Magazine × selected damage ÷ ((Magazine ÷ (RPM ÷ 60)) + reload seconds)</p>
<p>This does not calculate accuracy, critical hits, headshots, Talents, enemy mitigation, rotations, target count, or encounter uptime.</p>
</div>
<p class="baseline-source">Source provenance: each result shows its catalog weapon ID above.</p>
</section>
<style>
.weapon-baseline{display:grid;gap:var(--shd-gap-md);padding:var(--shd-gap-md);border:1px solid var(--shd-model-border);border-top:2px solid var(--shd-orange);background:var(--shd-model-surface);min-width:0}
.baseline-head,.weapon-result-head{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--shd-gap-md)}
.baseline-head>div,.weapon-result-head>div{display:grid;gap:3px;min-width:0}
.eyebrow,.baseline-head p{margin:0;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}
.eyebrow span{display:inline-block;width:6px;height:6px;margin-right:4px;background:var(--shd-orange)}
h2,h3{margin:0;text-transform:uppercase;letter-spacing:.7px;color:var(--shd-text)}
h2{font-size:var(--shd-type-body)}h3{font-size:var(--shd-type-supporting)}
.baseline-head>strong{padding:5px 7px;border:1px solid var(--shd-orange);color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px;white-space:nowrap}
.baseline-intro,.baseline-contract p,.baseline-source{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}
.baseline-results{display:grid;gap:var(--shd-gap-sm)}
.weapon-result{display:grid;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-border);background:var(--shd-surface)}
.weapon-result-head span,.baseline-inputs small,.baseline-metrics span,.missing-label{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
.weapon-result-head code{max-width:45%;overflow:hidden;color:var(--shd-text-dim);font:var(--shd-type-telemetry) var(--shd-font-mono);text-overflow:ellipsis;white-space:nowrap}
.baseline-inputs{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border-block:1px solid var(--shd-border-soft)}
.baseline-inputs span{display:grid;gap:3px;padding:7px;border-left:1px solid var(--shd-border-soft);min-width:0}.baseline-inputs span:first-child{border-left:0}
.baseline-inputs b{font-size:var(--shd-type-supporting);font-weight:700;overflow-wrap:anywhere}
.baseline-metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--shd-gap-sm)}
.baseline-metrics>div{display:grid;gap:5px;padding:9px;border-left:2px solid var(--shd-success);background:var(--shd-bg);min-width:0}.baseline-metrics>div.unavailable{border-left-color:var(--shd-danger)}
.baseline-metrics span{display:grid;gap:2px}.baseline-metrics span small{color:var(--shd-text-dim);font-size:10px}.baseline-metrics strong{font-size:18px;color:var(--shd-success)}.baseline-metrics .unavailable strong{color:var(--shd-danger)}
.missing-label{margin:2px 0 0;color:var(--shd-danger)}.baseline-metrics ul{margin:0;padding-left:17px;color:var(--shd-danger);font:var(--shd-type-telemetry)/1.4 var(--shd-font-mono)}
.baseline-contract{display:grid;gap:5px;padding-top:var(--shd-gap-sm);border-top:1px solid var(--shd-border)}.baseline-contract b{color:var(--shd-text);letter-spacing:.7px}.baseline-source{font-size:var(--shd-type-telemetry)}
@media(max-width:620px){.baseline-head{display:grid}.baseline-inputs{grid-template-columns:repeat(2,minmax(0,1fr))}.baseline-inputs span:nth-child(odd){border-left:0}.baseline-inputs span:nth-child(n+3){border-top:1px solid var(--shd-border-soft)}.baseline-metrics{grid-template-columns:1fr}.weapon-result-head code{max-width:50%}}
@media(prefers-reduced-motion:reduce){.weapon-baseline *{scroll-behavior:auto;transition:none!important;animation:none!important}}
</style>
@@ -9,6 +9,8 @@
blankConditionalMechanicDraft,
currentHealthPercentBandPredicate,
eventFieldComparisonPredicate,
mechanicTargetingForMode,
mechanicTargetingMode,
parseConditionalMechanicDraft,
readCurrentHealthPercentBand,
readEventFieldComparison,
@@ -21,6 +23,7 @@
type ConditionalMechanicPoolDraft,
type EditablePredicateScalar,
type EventFieldComparison,
type MechanicTargetingMode,
type PredicateComparisonOperator,
type StatePoolThresholdComparison
} from '$lib/calculations/mechanic-editor';
@@ -182,7 +185,9 @@
}
function eventPredicateOperatorOptions(clause: ConditionalMechanicClauseDraft) {
return selectedEventPredicateField(clause)?.type === 'number'
const field = selectedEventPredicateField(clause);
if (field?.path === 'values.aiming') return predicateOperatorOptions.filter((option) => option.value === 'eq');
return field?.type === 'number'
? predicateOperatorOptions
: predicateOperatorOptions.filter((option) => option.value === 'eq' || option.value === 'ne');
}
@@ -190,15 +195,21 @@
function setClauseEvent(clauseIndex: number, event: string) {
const nextEvent = event as ConditionalMechanicClauseDraft['event'];
const clause = rule.clauses[clauseIndex];
const outcomes = nextEvent === 'weapon_hit'
? clause.outcomes
: clause.outcomes.map((outcome) => outcome.targeting?.kind === 'event_target_area'
? { ...outcome, targeting: undefined }
: outcome);
const fields = registeredEventFields(nextEvent);
const comparison = readEventFieldComparison(clause.if);
if (!comparison || !fields.length) {
updateClause(clauseIndex, { event: nextEvent });
updateClause(clauseIndex, { event: nextEvent, outcomes });
return;
}
const field = fields[0];
updateClause(clauseIndex, {
event: nextEvent,
outcomes,
if: eventFieldComparisonPredicate({
path: field.path,
operator: 'eq',
@@ -403,6 +414,69 @@
updateClause(clauseIndex, { outcomes });
}
function targetingOptions(clause: ConditionalMechanicClauseDraft, outcome: ConditionalMechanicOutcomeDraft) {
if (outcome.scope === 'allies' || outcome.scope === 'self_and_allies') {
return [
{ value: 'none', label: 'NONE' },
{ value: 'allies_within_radius', label: 'ALLIES IN RADIUS' }
];
}
if (outcome.scope === 'target') {
return [
{ value: 'none', label: 'NONE' },
{ value: 'enemies_within_radius', label: 'ENEMIES IN RADIUS · LEGACY' },
...(outcome.kind === 'direct_damage' && clause.event === 'weapon_hit'
? [{ value: 'event_target_explosion', label: 'HIT-TARGET EXPLOSION' }]
: [])
];
}
return [{ value: 'none', label: 'NONE' }];
}
function setOutcomeScope(clauseIndex: number, outcomeIndex: number, scope: MechanicOutcomeScope) {
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
const targeting = outcome.targeting;
const compatible = !targeting
|| (targeting.kind === 'event_target_area' && scope === 'target' && outcome.kind === 'direct_damage')
|| (targeting.kind === 'within_radius' && targeting.population === 'enemies' && scope === 'target')
|| (targeting.kind === 'within_radius' && targeting.population === 'allies' && (scope === 'allies' || scope === 'self_and_allies'));
updateOutcome(clauseIndex, outcomeIndex, { scope, targeting: compatible ? targeting : undefined });
}
function setOutcomeKind(clauseIndex: number, outcomeIndex: number, kind: ConditionalMechanicOutcomeDraft['kind']) {
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
updateOutcome(clauseIndex, outcomeIndex, {
kind,
resource: kind === 'restoration' || kind === 'resource_cost' ? outcome.resource ?? 'health' : undefined,
attributeId: kind === 'attribute_modifier' ? outcome.attributeId ?? '' : undefined,
delivery: kind === 'restoration' ? outcome.delivery : undefined,
durationSeconds: kind === 'restoration' ? outcome.durationSeconds : undefined,
targeting: outcome.targeting?.kind === 'event_target_area' && kind !== 'direct_damage'
? undefined
: outcome.targeting
});
}
function setOutcomeTargeting(clauseIndex: number, outcomeIndex: number, mode: MechanicTargetingMode) {
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
updateOutcome(clauseIndex, outcomeIndex, {
targeting: mechanicTargetingForMode(mode, outcome.targeting),
phase: mode === 'event_target_explosion' && outcome.phase !== 'on_event' && outcome.phase !== 'after_event'
? 'after_event'
: outcome.phase
});
}
function phaseOptions(outcome: ConditionalMechanicOutcomeDraft) {
return [
{ value: 'on_event', label: 'ON EVENT' },
{ value: 'after_event', label: 'AFTER EVENT' },
{ value: 'while_active', label: 'WHILE ACTIVE', disabled: outcome.targeting?.kind === 'event_target_area' },
{ value: 'on_enter', label: 'ON ENTER', disabled: outcome.targeting?.kind === 'event_target_area' },
{ value: 'on_exit', label: 'ON EXIT', disabled: outcome.targeting?.kind === 'event_target_area' }
];
}
function updateVariant(clauseIndex: number, outcomeIndex: number, variant: string, raw: string) {
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
const value = Number(raw);
@@ -684,6 +758,50 @@
});
}
function addAbilityDurationExtension(clauseIndex: number) {
updateClause(clauseIndex, {
mutations: [
...rule.clauses[clauseIndex].mutations,
{ kind: 'extend_duration', target: 'assigned_ability', seconds: 0.3 }
]
});
}
function addCooldownOnMax(clauseIndex: number) {
const stackPool = rule.pools.find((pool) => pool.max && pool.max > 0)?.key;
const cooldownPool = rule.pools.find((pool) => pool.scope === 'agent' && pool.expiresAfterSeconds)?.key;
if (!stackPool || !cooldownPool || stackPool === cooldownPool) return;
updateClause(clauseIndex, {
mutations: [
...rule.clauses[clauseIndex].mutations,
{ kind: 'start_cooldown_on_max', stackPool, cooldownPool }
]
});
}
function updateAbilityDurationExtension(clauseIndex: number, mutationIndex: number, raw: string) {
const mutations = [...rule.clauses[clauseIndex].mutations];
const seconds = Number(raw);
mutations[mutationIndex] = {
kind: 'extend_duration',
target: 'assigned_ability',
seconds: Number.isFinite(seconds) ? seconds : 0
};
updateClause(clauseIndex, { mutations });
}
function updateCooldownOnMax(
clauseIndex: number,
mutationIndex: number,
patch: { stackPool?: string; cooldownPool?: string }
) {
const mutations = [...rule.clauses[clauseIndex].mutations];
const current = mutations[mutationIndex];
if (current.kind !== 'start_cooldown_on_max') return;
mutations[mutationIndex] = { ...current, ...patch };
updateClause(clauseIndex, { mutations });
}
function updateMutation(clauseIndex: number, mutationIndex: number, kind: 'add' | 'set' | 'consume' | 'clear', pool?: string, value?: number) {
const mutations = [...rule.clauses[clauseIndex].mutations];
const current = mutations[mutationIndex];
@@ -793,7 +911,7 @@
<section class="scenario-panel">
<header><div><span>BUILDER CONTROLS</span><strong>SCENARIO ASSUMPTIONS</strong></div><button type="button" class="secondary compact-action" onclick={addScenarioInput}>ADD CONTROL +</button></header>
<p>These controls appear in a build’s advanced mechanic settings. Maximum Potential uses the best-case value; Custom Scenario uses the Agent’s saved value.</p>
<p>These controls are retained for the planned advanced build model. Maximum Potential will use the best-case value; Custom Scenario will use the Agent’s saved value.</p>
{#each rule.scenarioInputs as input, index}
<div class="scenario-row">
<label class="field">LABEL<input value={input.label} oninput={(event) => updateScenarioInput(index,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label>
@@ -981,6 +1099,20 @@
{:else if conditionModel(clause) === 'advanced'}
<p class="advanced-condition-note">This saved condition remains active and is preserved unchanged. Use the generated mechanic preview to inspect it.</p>
{/if}
{#if allowAssignedAbility}
<div class="ability-proc-controls">
<SegmentedChoice
legend="ASSIGNED ABILITY"
name={`${idPrefix}-assigned-ability-${clauseIndex}`}
options={[{value:'false',label:'ANY STATE'},{value:'true',label:'MUST BE ACTIVE'}]}
value={String(clause.requiresAssignedAbilityActive ?? false)}
onchange={(values) => updateClause(clauseIndex,{requiresAssignedAbilityActive:values[0] === 'true' ? true : undefined})}
/>
<label class="field">PROC CHANCE · %<input type="number" min="0" max="100" step="any" value={clause.procChancePercent ?? ''} oninput={(event) => updateClause(clauseIndex,{procChancePercent:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/><small>Rolls after conditions and cooldown checks, before mutations.</small></label>
</div>
{:else}
<label class="field proc-chance-field">PROC CHANCE · %<input type="number" min="0" max="100" step="any" value={clause.procChancePercent ?? ''} oninput={(event) => updateClause(clauseIndex,{procChancePercent:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/><small>Rolls after conditions and cooldown checks, before mutations.</small></label>
{/if}
</div>
<div class="reducer-controls">
<SegmentedChoice
@@ -1007,38 +1139,53 @@
</div>
<div class="mutation-panel">
<div class="outcome-heading"><div><span>STATE CHANGES</span><strong>{clause.mutations.length}</strong></div><button type="button" class="secondary compact-action" onclick={() => addMutation(clauseIndex)} disabled={!rule.pools.length}>ADD STATE CHANGE +</button></div>
<div class="outcome-heading"><div><span>STATE CHANGES</span><strong>{clause.mutations.length}</strong></div><div class="mutation-actions"><button type="button" class="secondary compact-action" onclick={() => addMutation(clauseIndex)} disabled={!rule.pools.length}>ADD STATE CHANGE +</button>{#if allowAssignedAbility}<button type="button" class="secondary compact-action" onclick={() => addAbilityDurationExtension(clauseIndex)}>EXTEND ABILITY +</button>{/if}<button type="button" class="secondary compact-action" onclick={() => addCooldownOnMax(clauseIndex)} disabled={rule.pools.filter((pool) => pool.max && pool.max > 0).length < 2}>ADD MAX COOLDOWN +</button></div></div>
{#each clause.mutations as mutation, mutationIndex}
<div class="mutation-row">
<SegmentedChoice
legend="OPERATION"
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'clear',label:'CLEAR'}]}
value={mutation.kind === 'none' || mutation.kind === 'derive' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'clear')}
/>
<SegmentedChoice
legend="STATE POOL"
name={`${idPrefix}-mutation-pool-${clauseIndex}-${mutationIndex}`}
options={rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))}
value={'pool' in mutation ? mutation.pool : rule.pools[0]?.key ?? ''}
columns={Math.min(4,Math.max(1,rule.pools.length))}
onchange={(values) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'clear' ? 'clear' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])}
/>
{#if mutation.kind !== 'clear'}
<label class="field">VALUE<input type="number" step="any" value={'value' in mutation && typeof mutation.value === 'number' ? mutation.value : 1} oninput={(event) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',undefined,Number(event.currentTarget.value))}/></label>
{/if}
{#if 'pool' in mutation && rule.pools.find((pool) => pool.key === mutation.pool)?.scope === 'target'}
{#if mutation.kind === 'extend_duration'}
<div class="mutation-row ability-mutation-row">
<div class="branch-readout"><span>OPERATION</span><strong>EXTEND ASSIGNED ABILITY</strong></div>
<label class="field">SECONDS<input type="number" min="0" step="any" value={typeof mutation.seconds === 'number' ? mutation.seconds : 0} oninput={(event) => updateAbilityDurationExtension(clauseIndex,mutationIndex,event.currentTarget.value)}/><small>Applies only while the assigned ability remains active.</small></label>
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
</div>
{:else if mutation.kind === 'start_cooldown_on_max'}
<div class="mutation-row cooldown-mutation-row">
<div class="branch-readout"><span>OPERATION</span><strong>START COOLDOWN ON MAX</strong></div>
<SegmentedChoice legend="STACK POOL" name={`${idPrefix}-cooldown-stack-${clauseIndex}-${mutationIndex}`} options={rule.pools.filter((pool) => pool.max && pool.max > 0).map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))} value={mutation.stackPool} onchange={(values) => updateCooldownOnMax(clauseIndex,mutationIndex,{stackPool:values[0]})}/>
<SegmentedChoice legend="COOLDOWN POOL" name={`${idPrefix}-cooldown-pool-${clauseIndex}-${mutationIndex}`} options={rule.pools.filter((pool) => pool.scope === 'agent' && pool.expiresAfterSeconds && pool.key !== mutation.stackPool).map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))} value={mutation.cooldownPool} onchange={(values) => updateCooldownOnMax(clauseIndex,mutationIndex,{cooldownPool:values[0]})}/>
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
</div>
{:else}
<div class="mutation-row">
<SegmentedChoice
legend="STATE OWNER"
name={`${idPrefix}-mutation-entity-${clauseIndex}-${mutationIndex}`}
options={[{value:'event_target',label:'EVENT TARGET'},{value:'event_actor',label:'EVENT ACTOR'}]}
value={'entity' in mutation && mutation.entity?.kind === 'event_actor' ? 'event_actor' : 'event_target'}
onchange={(values) => updateMutationEntity(clauseIndex,mutationIndex,values[0] as 'event_target'|'event_actor')}
legend="OPERATION"
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'clear',label:'CLEAR'}]}
value={mutation.kind === 'none' || mutation.kind === 'derive' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'clear')}
/>
{/if}
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
</div>
<SegmentedChoice
legend="STATE POOL"
name={`${idPrefix}-mutation-pool-${clauseIndex}-${mutationIndex}`}
options={rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))}
value={'pool' in mutation ? mutation.pool : rule.pools[0]?.key ?? ''}
columns={Math.min(4,Math.max(1,rule.pools.length))}
onchange={(values) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'clear' ? 'clear' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])}
/>
{#if mutation.kind !== 'clear'}
<label class="field">VALUE<input type="number" step="any" value={'value' in mutation && typeof mutation.value === 'number' ? mutation.value : 1} oninput={(event) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',undefined,Number(event.currentTarget.value))}/></label>
{/if}
{#if 'pool' in mutation && rule.pools.find((pool) => pool.key === mutation.pool)?.scope === 'target'}
<SegmentedChoice
legend="STATE OWNER"
name={`${idPrefix}-mutation-entity-${clauseIndex}-${mutationIndex}`}
options={[{value:'event_target',label:'EVENT TARGET'},{value:'event_actor',label:'EVENT ACTOR'}]}
value={'entity' in mutation && mutation.entity?.kind === 'event_actor' ? 'event_actor' : 'event_target'}
onchange={(values) => updateMutationEntity(clauseIndex,mutationIndex,values[0] as 'event_target'|'event_actor')}
/>
{/if}
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
</div>
{/if}
{:else}
<p class="empty-state">This branch emits outcomes without changing shared state.</p>
{/each}
@@ -1049,25 +1196,24 @@
<div class="outcome-card">
<div class="outcome-grid">
<label class="field">LABEL<input value={outcome.label} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label>
<SegmentedChoice legend="KIND" name={`${idPrefix}-kind-${clauseIndex}-${outcomeIndex}`} options={outcomeKinds} value={outcome.kind} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{kind:values[0] as ConditionalMechanicOutcomeDraft['kind'],resource:values[0] === 'restoration' || values[0] === 'resource_cost' ? outcome.resource ?? 'health' : undefined,attributeId:values[0] === 'attribute_modifier' ? outcome.attributeId ?? '' : undefined,delivery:values[0] === 'restoration' ? outcome.delivery : undefined,durationSeconds:values[0] === 'restoration' ? outcome.durationSeconds : undefined})}/>
<SegmentedChoice legend="KIND" name={`${idPrefix}-kind-${clauseIndex}-${outcomeIndex}`} options={outcomeKinds} value={outcome.kind} onchange={(values) => setOutcomeKind(clauseIndex,outcomeIndex,values[0] as ConditionalMechanicOutcomeDraft['kind'])}/>
<label class="field">DESCRIPTION SLOT<input type="number" min="1" step="1" value={outcome.descriptionSlot ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{descriptionSlot:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
</div>
<SegmentedChoice legend="TARGET SCOPE" name={`${idPrefix}-scope-${clauseIndex}-${outcomeIndex}`} options={scopeOptions} value={outcome.scope} columns={scopeOptions.length > 4 ? 4 : scopeOptions.length} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{scope:values[0] as MechanicOutcomeScope})}/>
<SegmentedChoice legend="TARGET SCOPE" name={`${idPrefix}-scope-${clauseIndex}-${outcomeIndex}`} options={scopeOptions} value={outcome.scope} columns={scopeOptions.length > 4 ? 4 : scopeOptions.length} onchange={(values) => setOutcomeScope(clauseIndex,outcomeIndex,values[0] as MechanicOutcomeScope)}/>
{#if outcome.scope === 'allies' || outcome.scope === 'self_and_allies' || outcome.scope === 'target'}
<div class="delivery-controls">
<SegmentedChoice
legend="AREA TARGETING"
name={`${idPrefix}-targeting-${clauseIndex}-${outcomeIndex}`}
options={[{value:'none',label:'NONE'},{value:'allies',label:'ALLIES IN RADIUS'},{value:'enemies',label:'ENEMIES IN RADIUS'}]}
value={outcome.targeting?.population ?? 'none'}
onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{
targeting: values[0] === 'none'
? undefined
: {kind:'within_radius',population:values[0] as 'allies'|'enemies',radiusMeters:outcome.targeting?.radiusMeters ?? 20}
})}
options={targetingOptions(clause,outcome)}
value={mechanicTargetingMode(outcome.targeting)}
onchange={(values) => setOutcomeTargeting(clauseIndex,outcomeIndex,values[0] as MechanicTargetingMode)}
/>
{#if outcome.targeting}
<label class="field">RADIUS (METERS)<input type="number" min="0.01" step="any" value={outcome.targeting.radiusMeters} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{targeting:{...outcome.targeting!,radiusMeters:Number(event.currentTarget.value)}})}/></label>
{#if outcome.targeting.kind === 'event_target_area'}
<p class="area-targeting-note"><strong>EVENT TARGET CENTER</strong><span>Damages each valid enemy at or inside the radius once. Runtime events must supply the authoritative target sample.</span></p>
{/if}
{/if}
</div>
{/if}
@@ -1154,8 +1300,9 @@
<label class="field">{column.label} VALUE<input type="number" step="any" value={outcome.variants[column.variantKey]?.value ?? 0} oninput={(event) => updateVariant(clauseIndex,outcomeIndex,column.variantKey,event.currentTarget.value)}/></label>
{/each}
</div>
<label class="field probability-field">OUTCOME CHANCE · %<input type="number" min="0" max="100" step="any" value={outcome.probabilityPercent ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{probabilityPercent:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/><small>Each eligible outcome rolls independently. Maximum Potential resolves each nonzero chance as successful.</small></label>
<div class="outcome-footer">
<SegmentedChoice legend="PHASE" name={`${idPrefix}-phase-${clauseIndex}-${outcomeIndex}`} options={[{value:'on_event',label:'ON EVENT'},{value:'after_event',label:'AFTER EVENT'},{value:'while_active',label:'WHILE ACTIVE'},{value:'on_enter',label:'ON ENTER'},{value:'on_exit',label:'ON EXIT'}]} value={outcome.phase} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{phase:values[0] as OutcomePhase})}/>
<SegmentedChoice legend="PHASE" name={`${idPrefix}-phase-${clauseIndex}-${outcomeIndex}`} options={phaseOptions(outcome)} value={outcome.phase} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{phase:values[0] as OutcomePhase})}/>
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{outcomes:clause.outcomes.filter((_,index) => index !== outcomeIndex)})}>REMOVE OUTCOME</button>
</div>
</div>
@@ -1198,6 +1345,8 @@
.branch>header>div{display:flex;align-items:end;gap:9px}.branch>header>div>span{align-self:center;color:var(--shd-orange);font:900 var(--shd-type-telemetry)/1 var(--shd-font-mono)}.branch>header .field{min-width:min(360px,55vw);margin:0}
.branch-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr)) minmax(160px,.45fr);align-items:end;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)}
.condition-controls{display:grid;grid-template-columns:minmax(260px,.7fr) minmax(320px,1.3fr);align-items:end;gap:var(--shd-gap-sm);padding:0 var(--shd-gap-sm) var(--shd-gap-sm)}
.ability-proc-controls{display:grid;grid-template-columns:minmax(240px,.7fr) minmax(180px,.4fr);align-items:end;gap:var(--shd-gap-sm)}
.ability-proc-controls .field small,.proc-chance-field small,.probability-field small,.ability-mutation-row .field small{display:block;margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.3}
.health-boundaries{display:grid;grid-template-columns:repeat(2,minmax(150px,1fr));gap:var(--shd-gap-sm)}
.health-boundaries .field small{display:block;margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.3}
.predicate-fields{display:grid;grid-template-columns:minmax(170px,1fr) minmax(190px,.8fr);align-items:end;gap:var(--shd-gap-sm)}
@@ -1207,12 +1356,15 @@
.branch-readout{display:grid;align-content:center;gap:3px;min-height:44px;padding:7px 9px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}.branch-readout span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.branch-readout strong{font-size:var(--shd-type-supporting)}
.reducer-controls{display:grid;grid-template-columns:minmax(0,1fr) repeat(2,minmax(130px,.25fr));align-items:end;gap:var(--shd-gap-sm);padding:0 var(--shd-gap-sm) var(--shd-gap-sm)}
.mutation-panel{margin:0 var(--shd-gap-sm) var(--shd-gap-sm);border:1px solid var(--shd-border-soft)}
.mutation-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}
.mutation-row{display:grid;grid-template-columns:minmax(260px,1fr) minmax(220px,1fr) minmax(110px,.35fr) minmax(240px,1fr) auto;align-items:end;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border-soft)}
.ability-mutation-row{grid-template-columns:minmax(240px,1fr) minmax(180px,.5fr) auto}.cooldown-mutation-row{grid-template-columns:minmax(210px,.8fr) minmax(220px,1fr) minmax(220px,1fr) auto}.probability-field{max-width:260px}
.outcome-card{display:grid;gap:8px;margin:var(--shd-gap-sm);padding:9px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}
.outcome-grid{display:grid;grid-template-columns:minmax(200px,1fr) minmax(360px,1.4fr) minmax(120px,.3fr);align-items:end;gap:8px}
.variant-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px}
.state-scaling{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr) repeat(2,minmax(110px,.3fr));align-items:end;gap:8px}
.delivery-controls{display:grid;grid-template-columns:minmax(260px,1fr) minmax(150px,.35fr);align-items:end;gap:8px}.delivery-controls>.field{margin:0}
.area-targeting-note{grid-column:1/-1;display:grid;gap:2px;margin:0;padding:7px 9px;border-left:2px solid var(--shd-info);background:var(--shd-model-surface);color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.area-targeting-note strong{color:var(--shd-info);font-size:var(--shd-type-telemetry);letter-spacing:.7px}
.outcome-footer{display:flex;align-items:end;justify-content:space-between;gap:8px}
.remove,.danger-quiet{min-height:38px;padding:0 10px;border:1px solid var(--shd-danger);background:transparent;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}
.remove:hover,.remove:focus-visible,.danger-quiet:hover,.danger-quiet:focus-visible{background:var(--shd-danger);color:var(--shd-surface)}
+15
View File
@@ -3,6 +3,11 @@ import type { GameDataTargetType } from '$lib/game-data-targets';
export type DatabaseBadge = { label: string; tone?: string };
export type DatabaseMeter = { label: string; value: number };
export type DatabaseWeaponModSlot = {
slot: string;
unlockedAtTierOne: boolean;
iconUrl?: string;
};
export type DatabaseFact = {
label: string;
value: string;
@@ -26,8 +31,10 @@ export type DatabaseItem = {
renderUrl?: string;
gameIcon?: GameIconName;
headerBadge?: string;
headerBadgeTone?: string;
headerBadges?: string[];
headerMetaIcon?: GameIconName;
topBorderTone?: string;
badges?: DatabaseBadge[];
facts: DatabaseFact[];
factLayout?: 'grid' | 'stack';
@@ -35,9 +42,17 @@ export type DatabaseItem = {
lines?: DatabaseFact[];
afterLines?: DatabaseFact[];
iconGroups?: DatabaseIconGroup[];
weaponModSlots?: DatabaseWeaponModSlot[];
description?: string;
secondaryDescription?: string;
descriptionStyle?: 'prose' | 'data';
protocolPreview?: {
primaryStat: string;
primaryUnit: string;
primaryBaseValue: number;
talentDescription?: string;
talentEffects: Array<{ baseValue: number; unit: string }>;
};
};
export type DatabaseRelatedItem = {
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest';
import {
MOD_SECONDARY_CAPACITY,
normalizeModSecondaryPool,
} from './secondary-pool';
const endpoints = (
level1Min: number | string,
level1Max: number | string,
level20Min: number | string,
level20Max: number | string,
) => ({ level1Min, level1Max, level20Min, level20Max });
const range = (statId: string) => ({
statId,
values: {
Specialized: endpoints('1.5', '2.5', '3.5', '4.5'),
Superior: endpoints(2, 3, 4, 5),
'High-end': endpoints(3, 4, 5, 6),
},
});
describe('shared Mod secondary pool', () => {
it('defines secondary capacity for every Mod quality', () => {
expect(MOD_SECONDARY_CAPACITY).toEqual({
Standard: 0,
Specialized: 1,
Superior: 2,
'High-end': 2,
});
});
it('normalizes Attribute IDs and valid Level 1/20 endpoints', () => {
const result = normalizeModSecondaryPool([
range(' critical-hit-chance '),
range('critical-hit-damage'),
]);
expect(result.error).toBeNull();
expect(result.pool).toEqual([
{
statId: 'critical-hit-chance',
values: {
Specialized: endpoints(1.5, 2.5, 3.5, 4.5),
Superior: endpoints(2, 3, 4, 5),
'High-end': endpoints(3, 4, 5, 6),
},
},
{
statId: 'critical-hit-damage',
values: {
Specialized: endpoints(1.5, 2.5, 3.5, 4.5),
Superior: endpoints(2, 3, 4, 5),
'High-end': endpoints(3, 4, 5, 6),
},
},
]);
});
it('rejects duplicate Attributes after ID normalization', () => {
const result = normalizeModSecondaryPool([
range('critical-hit-chance'),
range(' critical-hit-chance '),
]);
expect(result).toEqual({
pool: [],
error: 'Choose a unique Attribute for every shared pool option.',
});
});
it.each([
[
'missing quality ranges',
{
...range('critical-hit-chance'),
values: {
Specialized: endpoints(1, 2, 3, 4),
Superior: endpoints(2, 3, 4, 5),
},
},
],
[
'reversed ranges',
{
...range('critical-hit-chance'),
values: {
...range('critical-hit-chance').values,
Specialized: endpoints(2, 1, 3, 4),
},
},
],
[
'Level 20 regression',
{
...range('critical-hit-chance'),
values: {
...range('critical-hit-chance').values,
Specialized: endpoints(2, 3, 1, 2),
},
},
],
])('rejects %s', (_label, invalidRange) => {
const result = normalizeModSecondaryPool([
invalidRange,
range('critical-hit-damage'),
]);
expect(result).toEqual({
pool: [],
error:
'Configure valid Level 1 and Level 20 ranges for Specialized, Superior, and High-end quality.',
});
});
});
+102
View File
@@ -0,0 +1,102 @@
export const MOD_QUALITIES = [
'Standard',
'Specialized',
'Superior',
'High-end',
] as const;
export const MOD_SECONDARY_QUALITIES = [
'Specialized',
'Superior',
'High-end',
] as const;
export const MOD_SECONDARY_CAPACITY = {
Standard: 0,
Specialized: 1,
Superior: 2,
'High-end': 2,
} as const satisfies Record<(typeof MOD_QUALITIES)[number], number>;
export type ModSecondaryQuality = (typeof MOD_SECONDARY_QUALITIES)[number];
export type ModSecondaryRange = {
statId: string;
values: Record<
ModSecondaryQuality,
{
level1Min: number;
level1Max: number;
level20Min: number;
level20Max: number;
}
>;
};
export function normalizeModSecondaryPool(input: unknown): {
pool: ModSecondaryRange[];
error: string | null;
} {
if (!Array.isArray(input) || input.length < 2 || input.length > 50) {
return {
pool: [],
error: 'Configure between two and 50 shared secondary Attributes.',
};
}
const pool = input.map((candidate) => {
const range = candidate as Partial<ModSecondaryRange>;
return {
statId: typeof range.statId === 'string' ? range.statId.trim() : '',
values: Object.fromEntries(
MOD_SECONDARY_QUALITIES.map((quality) => {
const values = range.values?.[quality] as
| Partial<ModSecondaryRange['values'][ModSecondaryQuality]>
| undefined;
return [
quality,
{
level1Min: Number(values?.level1Min),
level1Max: Number(values?.level1Max),
level20Min: Number(values?.level20Min),
level20Max: Number(values?.level20Max),
},
];
}),
) as ModSecondaryRange['values'],
};
});
if (
pool.some((range) => !range.statId) ||
new Set(pool.map((range) => range.statId)).size !== pool.length
) {
return {
pool: [],
error: 'Choose a unique Attribute for every shared pool option.',
};
}
if (
pool.some((range) =>
MOD_SECONDARY_QUALITIES.some((quality) => {
const values = range.values[quality];
return (
Object.values(values).some((value) => !Number.isFinite(value)) ||
values.level1Max < values.level1Min ||
values.level20Max < values.level20Min ||
values.level20Min < values.level1Min ||
values.level20Max < values.level1Max
);
}),
)
) {
return {
pool: [],
error:
'Configure valid Level 1 and Level 20 ranges for Specialized, Superior, and High-end quality.',
};
}
return { pool, error: null };
}
@@ -94,7 +94,7 @@ describe('AI mechanic authoring prompt', () => {
}),
);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(7);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
expect.arrayContaining(['delivery', 'durationSeconds']),
);
@@ -136,7 +136,7 @@ describe('AI mechanic authoring prompt', () => {
]),
);
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
'Prompt contract version: 7',
'Prompt contract version: 10',
);
const advertised = structuredClone(
@@ -185,7 +185,7 @@ describe('AI mechanic authoring prompt', () => {
expect(parsed.ok).toBe(true);
});
it('advertises the canonical Extra Health change event contract in version 7', () => {
it('advertises the canonical Extra Health change event contract in version 10', () => {
const input = JSON.parse(
buildAiMechanicAuthoringInput({
request,
@@ -196,9 +196,9 @@ describe('AI mechanic authoring prompt', () => {
}),
);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(7);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
'Prompt contract version: 7',
'Prompt contract version: 10',
);
expect(input.canonicalCatalog.events).toContainEqual({
key: 'extra_health_changed',
@@ -222,6 +222,144 @@ describe('AI mechanic authoring prompt', () => {
});
});
it('advertises assigned-ability activity for kill and assist predicates', () => {
const input = JSON.parse(
buildAiMechanicAuthoringInput({
request,
attributes: [],
events: [
MECHANIC_EVENT_REGISTRY.enemy_killed,
MECHANIC_EVENT_REGISTRY.assist,
],
actions: [],
recipeKeys: [],
}),
);
for (const event of input.canonicalCatalog.events) {
expect(event.fields).toEqual(
expect.arrayContaining([
{
path: 'values.assignedAbilityActive',
label: 'Assigned ability active',
type: 'boolean',
},
]),
);
}
expect(input.examplesAndRules).toEqual(
expect.arrayContaining([
expect.stringContaining('values.assignedAbilityActive'),
]),
);
});
it('advertises and round-trips aimed event-target area damage', () => {
const protocolRequest: AiMechanicAuthoringRequest = {
...request,
context: {
sourceKind: 'os_protocol_talent',
sourceName: 'Untitled mechanic',
sourceDescription:
'Critical Hits performed during aiming create an explosion with a radius of 5 meters around the target that deals damage equal to 2000% Firepower to them.',
},
valueColumns: [{ key: 'base', variantKey: 'base', label: 'BASE' }],
allowProtocolUpgradeScaling: true,
};
const input = JSON.parse(
buildAiMechanicAuthoringInput({
request: protocolRequest,
attributes: [
{
id: 'firepower-id',
name: 'Firepower',
description: null,
unit: '',
type: null,
category: null,
calculationType: null,
},
],
events: [MECHANIC_EVENT_REGISTRY.weapon_hit],
actions: [],
recipeKeys: [],
}),
);
expect(input.canonicalCatalog.events[0].fields).toContainEqual({
path: 'values.aiming',
label: 'Aiming down sights',
type: 'boolean',
});
expect(
input.canonicalCatalog.grammar.outcome.targeting.eventTargetArea,
).toMatchObject({
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
});
expect(input.examplesAndRules).toEqual(
expect.arrayContaining([
expect.stringContaining('values.aiming'),
expect.stringContaining('event_target_area'),
]),
);
const advertised = structuredClone(
input.canonicalCatalog.grammar.aimedCriticalAreaDamageShape,
);
const outcome = advertised.clauses[0].outcomes[0];
const base = JSON.parse(
JSON.stringify(
outcome.variants['replace-with-each-required-variant-key'],
).replaceAll(
'replace-with-an-exact-catalog-attribute-id',
'firepower-id',
),
);
outcome.variants = { base };
const parsed = parseConditionalMechanicDraft(advertised, {
variantKeys: ['base'],
allowedAttributeIds: new Set(['firepower-id']),
});
expect(parsed.ok).toBe(true);
});
it('advertises reproducible outcome and clause proc probabilities in version 10', () => {
const input = JSON.parse(
buildAiMechanicAuthoringInput({
request,
attributes: [],
events: [MECHANIC_EVENT_REGISTRY.skill_hit],
actions: [],
recipeKeys: [],
}),
);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
expect(input.canonicalCatalog.grammar.clause.optional).toEqual(
expect.arrayContaining([
'procChancePercent',
'requiresAssignedAbilityActive',
]),
);
expect(input.canonicalCatalog.grammar.mutationKinds).toEqual(
expect.arrayContaining(['extend_duration', 'start_cooldown_on_max']),
);
expect(
input.canonicalCatalog.grammar.probabilityOutcomeShape,
).toMatchObject({
probabilityPercent: 29,
resource: 'ammo',
});
expect(input.examplesAndRules).toEqual(
expect.arrayContaining([
expect.stringContaining('procChancePercent'),
expect.stringContaining('reproducible seeded random stream'),
]),
);
});
it('advertises authoritative current Health percentage with strict band semantics', () => {
const input = JSON.parse(
buildAiMechanicAuthoringInput({
+107 -2
View File
@@ -3,7 +3,7 @@ import { CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES } from '$lib/calculations/mec
import { MECHANIC_COMBAT_REFERENCE_REGISTRY } from '$lib/calculations/mechanic';
import type { AiMechanicAttribute } from './mechanic-authoring-context';
export const AI_MECHANIC_PROMPT_VERSION = 7;
export const AI_MECHANIC_PROMPT_VERSION = 10;
const mechanicsGrammar = {
version: 1,
@@ -40,7 +40,14 @@ const mechanicsGrammar = {
},
clause: {
required: ['key', 'event', 'if', 'reducer', 'mutations', 'outcomes'],
optional: ['label', 'recipeKey', 'cooldownSeconds', 'weight'],
optional: [
'label',
'recipeKey',
'cooldownSeconds',
'weight',
'procChancePercent',
'requiresAssignedAbilityActive',
],
},
predicateKinds: ['true', 'exists', 'compare', 'all', 'any', 'not'],
compareOperators: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'],
@@ -63,6 +70,8 @@ const mechanicsGrammar = {
'derive',
'clear',
'transfer',
'extend_duration',
'start_cooldown_on_max',
],
entitySelectors: ['agent', 'event_actor', 'event_target', 'event_field'],
valueReferences: [
@@ -70,6 +79,7 @@ const mechanicsGrammar = {
'scenario',
'combat',
'equipment',
'assigned_ability',
'attribute',
'reducer',
'pool',
@@ -97,6 +107,7 @@ const mechanicsGrammar = {
},
},
{ ref: { source: 'equipment', path: 'protocolTalentUpgrades' } },
{ ref: { source: 'assigned_ability', path: 'remainingDurationSeconds' } },
{
ref: {
source: 'attribute',
@@ -200,6 +211,16 @@ const mechanicsGrammar = {
value: 'all',
clearSource: true,
},
{
kind: 'extend_duration',
target: 'assigned_ability',
seconds: 0.3,
},
{
kind: 'start_cooldown_on_max',
stackPool: 'extension-stacks',
cooldownPool: 'extension-cooldown',
},
],
outcome: CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES,
continuousRestorationOutcomeShape: {
@@ -232,6 +253,83 @@ const mechanicsGrammar = {
},
},
},
probabilityOutcomeShape: {
key: 'recover-ammo',
label: 'Recover Ammo',
phase: 'after_event',
kind: 'restoration',
scope: 'self',
basis: 'flat',
resource: 'ammo',
probabilityPercent: 29,
variants: {
'replace-with-each-required-variant-key': { value: 1 },
},
},
aimedCriticalAreaDamageShape: {
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [],
clauses: [
{
key: 'aimed-critical-explosion',
label: 'Aimed critical hit explosion',
event: 'weapon_hit',
if: {
kind: 'all',
predicates: [
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
{
kind: 'compare',
left: { source: 'event', path: 'values.critical' },
operator: 'eq',
right: true,
},
{
kind: 'compare',
left: { source: 'event', path: 'values.aiming' },
operator: 'eq',
right: true,
},
],
},
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
key: 'critical-explosion-damage',
label: 'Critical explosion damage',
phase: 'after_event',
kind: 'direct_damage',
scope: 'target',
basis: 'flat',
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
},
variants: {
'replace-with-each-required-variant-key': {
value: 2000,
expression: {
op: 'multiply',
left: 20,
right: {
ref: {
source: 'attribute',
attributeId: 'replace-with-an-exact-catalog-attribute-id',
},
},
},
},
},
},
],
},
],
},
preciseTraumaShape: {
version: 1,
simulationReadiness: 'ready',
@@ -453,9 +551,16 @@ export function buildAiMechanicAuthoringInput(input: {
'A target-scoped pool owns an independent value and expiry timer for each entity. Bind hit-created target state with entity:{kind:"event_target"}; do not collapse different enemies into one Agent-scoped pool.',
'Writing a target-scoped pool refreshes only that target entity timer. An on_exit outcome runs when that entity expires and uses the entity previous state for stack bands and perStack multiplication, even though the stored pool value has already reset.',
'When an outcome value is exactly a live Attribute amount, use the Attribute reference directly as the variant expression. Divide by 100 only when a displayed percentage is being used as a multiplier.',
'A weapon_hit may compare values.aiming with true only when the wording requires aiming down sights at hit resolution. Missing aiming evidence does not match; do not replace it with a scenario assumption.',
'An explosion centered on the hit target uses target-scoped direct_damage with targeting kind event_target_area, center event_target, validTargets enemies, and the stated positive radiusMeters. The runtime applies one equal damage emission to each authoritative valid enemy at or inside the radius. Do not use legacy within_radius metadata for explosion damage.',
'Precise Trauma is representable as one target-scoped stack pool with a one-second expiry: body shots add one stack, headshots add two, and one on_exit direct-damage outcome uses the expiring target previous stack count. This models the stated one-second debuff without inventing generic repeated target ticks.',
'OS Protocol upgrade scaling may use equipment.protocolTalentUpgrades only when allowProtocolUpgradeScaling is true.',
'Assigned-ability scope may be used only when allowAssignedAbility is true.',
'When allowAssignedAbility is true, a kill or assist that must occur while the source item\'s assigned ability is active uses enemy_killed or assist with {source:"event",path:"values.assignedAbilityActive"} compared to true. This field is optional on the event; a missing value does not match.',
'When allowAssignedAbility is true, requiresAssignedAbilityActive gates a clause on the runtime assigned-ability state. skill_deployed and signature_ability_deployed may supply values.assignedAbilityDurationSeconds to begin that state. At an exact expiry timestamp, the ability expires before a later clause can extend it.',
'procChancePercent is a clause-level independent percentage roll after its predicate and cooldown checks but before the reducer, mutations, and outcomes. Use it when a successful proc changes state. probabilityPercent is an outcome-level independent percentage roll for one outcome only.',
'Custom simulation uses a reproducible seeded random stream. Maximum Potential resolves every nonzero chance as successful. Never use clause weight as a proc chance.',
'extend_duration may target only assigned_ability and does nothing while the ability is inactive. start_cooldown_on_max starts a configured expiring Agent cooldown pool only when a mutation in the same clause transitions its capped stack pool to its maximum.',
'If cadence is not stated, ask rather than assuming a one-second interval.',
'If wording contains separate actions such as restoration on a periodic snapshot and a health cost on every shot, represent them as separate clauses.',
'A pool with expiresAfterSeconds refreshes the shared expiry for every current stack whenever any mutation writes that pool. Use it for mechanics where gaining a stack refreshes the duration of the full stack pool.',
@@ -9,21 +9,35 @@ describe('AI mechanic authoring rate limit', () => {
beforeEach(() => resetAiMechanicRateLimitForTests());
it('allows only one in-flight request per account', () => {
const release = reserveAiMechanicGeneration('agent-1', 1_000);
expect(() => reserveAiMechanicGeneration('agent-1', 1_001)).toThrow(
const release = reserveAiMechanicGeneration('agent-1', false, 1_000);
expect(() => reserveAiMechanicGeneration('agent-1', false, 1_001)).toThrow(
AiMechanicRateLimitError,
);
release();
expect(() => reserveAiMechanicGeneration('agent-1', 1_002)).not.toThrow();
expect(() =>
reserveAiMechanicGeneration('agent-1', false, 1_002),
).not.toThrow();
});
it('enforces the hourly account limit', () => {
for (let request = 0; request < 10; request += 1) {
const release = reserveAiMechanicGeneration('agent-1', 10_000 + request);
const release = reserveAiMechanicGeneration(
'agent-1',
false,
10_000 + request,
);
release();
}
expect(() => reserveAiMechanicGeneration('agent-1', 10_100)).toThrow(
expect(() => reserveAiMechanicGeneration('agent-1', false, 10_100)).toThrow(
'This account has reached the hourly mechanic authoring limit.',
);
});
it('does not limit administrators', () => {
const releases = Array.from({ length: 101 }, () =>
reserveAiMechanicGeneration('admin-1', true, 10_000),
);
for (const release of releases) release();
});
});
@@ -27,7 +27,12 @@ const retryAfter = (requests: number[], now: number) =>
* duplicate submissions and basic abuse; OpenAI project budgets remain the hard
* cross-instance spending boundary.
*/
export function reserveAiMechanicGeneration(userId: string, now = Date.now()) {
export function reserveAiMechanicGeneration(
userId: string,
isAdmin = false,
now = Date.now(),
) {
if (isAdmin) return () => {};
if (inFlightUsers.has(userId)) {
throw new AiMechanicRateLimitError(
'A mechanic authoring request is already running for this account.',
+2 -2
View File
@@ -77,7 +77,7 @@ export async function syncArticleTags(database: Database) {
`specialization-${item.id}`,
'specialization',
String(item.name),
`specialization-${slugify(String(item.name))}`,
`specialization-${slugify(String(item.name))}-${item.id}`,
'specialization',
item.id,
Number(item.active) ? 1 : 0,
@@ -93,7 +93,7 @@ export async function syncArticleTags(database: Database) {
`focus-${item.id}`,
'focus',
name,
`focus-${slugify(name)}`,
`focus-${slugify(name)}-${item.id}`,
'focus',
item.id,
Number(item.active) ? 1 : 0,
+2 -1
View File
@@ -34,6 +34,7 @@ export async function recordIsacAudit(input: {
export async function auditRecord(
table: string,
id: string,
suppliedDatabase?: Database,
): Promise<Record<string, unknown> | null> {
if (
![
@@ -56,7 +57,7 @@ export async function auditRecord(
) {
throw new Error('Unsupported audit table');
}
const database = await getDatabase();
const database = suppliedDatabase ?? (await getDatabase());
return (
(await (
await database.prepare(`SELECT * FROM ${table} WHERE id=?`)
+105
View File
@@ -0,0 +1,105 @@
import sharp from 'sharp';
import { describe, expect, it } from 'vitest';
import {
BUILD_SOCIAL_CARD_HEIGHT,
BUILD_SOCIAL_CARD_WIDTH,
buildBuildSocialCardSvg,
renderBuildSocialCardPng,
type BuildSocialCardInput,
} from './build-social-card';
const input: BuildSocialCardInput = {
title: 'Vanguard Counter-Offensive',
author: 'Agent Kelso',
primaryWeapon: 'Police M4',
secondaryWeapon: 'Super 90',
firepower: 1280,
toughness: 940,
engineering: 615,
supportingAttributes: [
{ label: 'Critical Hit Chance', value: '18.5%' },
{ label: 'Headshot Damage', value: '42%' },
{ label: 'Armor', value: '12,400' },
],
visibility: 'public',
directUrl: 'shd.build/builds/vanguard-counter-offensive',
};
describe('build social card', () => {
it('escapes XML injection and keeps inputs out of markup and URLs', () => {
const attack = `</text><script href="https://attacker.invalid">alert('x') & more</script>`;
const svg = buildBuildSocialCardSvg({
...input,
title: attack,
author: `A\u0000gent <&> "one" 'two'`,
directUrl: `javascript:alert(1)\n${attack}`,
supportingAttributes: [{ label: attack, value: attack }],
});
expect(svg).not.toContain('<script');
expect(svg).not.toContain('href="https://attacker.invalid"');
expect(svg).not.toContain('\u0000');
expect(svg).toContain(
'&lt;/text&gt;&lt;script href=&quot;https://attacker.invalid&quot;&gt;',
);
expect(svg).toContain(
'A gent &lt;&amp;&gt; &quot;one&quot; &apos;two&apos;',
);
expect(svg).not.toMatch(/<[^>]+\s(?:href|xlink:href)\s*=/gu);
});
it('wraps and truncates long Unicode text without splitting graphemes', () => {
const family = '👨‍👩‍👧‍👦';
const svg = buildBuildSocialCardSvg({
...input,
title: `${family.repeat(31)} ${family.repeat(31)} ${family.repeat(31)}`,
author: `Zoë ${'e\u0301'.repeat(60)}`,
directUrl: `shd.build/builds/${'路'.repeat(130)}`,
});
const titleBlock =
svg.match(/<text class="title">(.*?)<\/text>/u)?.[1] ?? '';
expect(titleBlock.match(/<tspan/gu)).toHaveLength(2);
expect(titleBlock).toContain('…');
expect(titleBlock).not.toMatch(
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/gu,
);
expect(svg).toContain('é');
expect(svg).not.toContain('é');
expect(svg).toContain(`${'路'.repeat(20)}`);
expect(svg).toContain('…');
});
it('builds deterministic SVG with bounded supporting facts', () => {
const withExtraFacts = {
...input,
supportingAttributes: [
...input.supportingAttributes!,
{ label: 'Must not render', value: '999' },
],
};
const first = buildBuildSocialCardSvg(withExtraFacts);
const second = buildBuildSocialCardSvg(withExtraFacts);
expect(first).toBe(second);
expect(first).toContain('width="1200" height="630"');
expect(first).toContain('SHD</text><text');
expect(first).toContain('.build');
expect(first).toContain('font-family:"DejaVu Sans Condensed",sans-serif');
expect(first).toContain('SUPPORTING ATTRIBUTES / 03');
expect(first).not.toContain('Must not render');
});
it('renders a valid 1200x630 PNG through Sharp', async () => {
const png = await renderBuildSocialCardPng(input);
const metadata = await sharp(png).metadata();
expect(png.subarray(0, 8)).toEqual(
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
);
expect(metadata.format).toBe('png');
expect(metadata.width).toBe(BUILD_SOCIAL_CARD_WIDTH);
expect(metadata.height).toBe(BUILD_SOCIAL_CARD_HEIGHT);
expect(png.byteLength).toBeLessThan(1_000_000);
});
});
+170
View File
@@ -0,0 +1,170 @@
import sharp from 'sharp';
export const BUILD_SOCIAL_CARD_WIDTH = 1200;
export const BUILD_SOCIAL_CARD_HEIGHT = 630;
export type BuildVisibility = 'public' | 'unlisted' | 'private';
export interface BuildSocialCardFact {
label: string;
value: string;
}
export interface BuildSocialCardInput {
title: string;
author: string;
primaryWeapon: string;
secondaryWeapon: string;
firepower: string | number;
toughness: string | number;
engineering: string | number;
supportingAttributes?: readonly BuildSocialCardFact[];
visibility: BuildVisibility;
directUrl: string;
}
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
function graphemes(value: string): string[] {
return Array.from(segmenter.segment(value), ({ segment }) => segment);
}
function normalizeText(value: string | number, maximum: number): string {
const normalized = String(value)
.normalize('NFC')
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' ')
.replace(/\s+/gu, ' ')
.trim();
const characters = graphemes(normalized);
if (characters.length <= maximum) return normalized;
return `${characters
.slice(0, Math.max(0, maximum - 1))
.join('')
.trimEnd()}…`;
}
function escapeXml(value: string): string {
return value
.replace(/&/gu, '&amp;')
.replace(/</gu, '&lt;')
.replace(/>/gu, '&gt;')
.replace(/"/gu, '&quot;')
.replace(/'/gu, '&apos;');
}
function wrapText(
value: string,
width: number,
maximumLines: number,
): string[] {
const characters = graphemes(value);
const lines: string[] = [];
let offset = 0;
while (offset < characters.length && lines.length < maximumLines) {
const remaining = characters.slice(offset);
if (remaining.length <= width) {
lines.push(remaining.join('').trim());
offset = characters.length;
break;
}
const candidate = remaining.slice(0, width + 1);
let splitAt = -1;
for (
let index = Math.min(width, candidate.length - 1);
index > 0;
index--
) {
if (/\s/u.test(candidate[index])) {
splitAt = index;
break;
}
}
if (splitAt < Math.floor(width * 0.55)) splitAt = width;
lines.push(remaining.slice(0, splitAt).join('').trim());
offset += splitAt;
while (offset < characters.length && /\s/u.test(characters[offset]))
offset++;
}
if (offset < characters.length && lines.length > 0) {
const last = graphemes(lines.at(-1) ?? '');
lines[lines.length - 1] = `${last
.slice(0, Math.max(0, width - 1))
.join('')
.trimEnd()}…`;
}
return lines.length > 0 ? lines : ['UNTITLED BUILD'];
}
function textLines(
lines: readonly string[],
x: number,
y: number,
lineHeight: number,
): string {
return lines
.map(
(line, index) =>
`<tspan x="${x}" y="${y + index * lineHeight}">${escapeXml(line)}</tspan>`,
)
.join('');
}
function visibilityLabel(visibility: BuildVisibility): string {
if (visibility === 'public') return 'PUBLIC';
if (visibility === 'unlisted') return 'UNLISTED';
return 'PRIVATE';
}
/** Builds the complete, text-only SVG supplied to Sharp. */
export function buildBuildSocialCardSvg(input: BuildSocialCardInput): string {
const title = wrapText(normalizeText(input.title, 112), 31, 2);
const author = normalizeText(input.author, 42) || 'UNKNOWN AGENT';
const primaryWeapon = normalizeText(input.primaryWeapon, 52) || 'UNSPECIFIED';
const secondaryWeapon =
normalizeText(input.secondaryWeapon, 52) || 'UNSPECIFIED';
const firepower = normalizeText(input.firepower, 18) || '—';
const toughness = normalizeText(input.toughness, 18) || '—';
const engineering = normalizeText(input.engineering, 18) || '—';
const directUrl = normalizeText(input.directUrl, 92) || 'shd.build';
const facts = (input.supportingAttributes ?? []).slice(0, 3).map((fact) => ({
label: normalizeText(fact.label.toUpperCase(), 28) || 'ATTRIBUTE',
value: normalizeText(fact.value, 30) || '—',
}));
const factMarkup = facts
.map((fact, index) => {
const y = 421 + index * 48;
return `<g><line x1="746" y1="${y + 13}" x2="764" y2="${y + 13}" class="signal"/><text x="778" y="${y}" class="fact-label">${escapeXml(fact.label)}</text><text x="778" y="${y + 22}" class="fact-value">${escapeXml(fact.value)}</text></g>`;
})
.join('');
return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-label="SHD.build build card">
<style>
text{font-family:"DejaVu Sans Condensed",sans-serif}.telemetry{font-size:13px;font-weight:700;letter-spacing:2px;fill:#858b8d}.title{font-size:54px;font-weight:900;letter-spacing:-1px;fill:#ecebe5}.weapon{font-size:25px;font-weight:800;fill:#ecebe5}.stat-label,.fact-label{font-size:12px;font-weight:800;letter-spacing:1.5px;fill:#858b8d}.stat-value{font-size:31px;font-weight:900;fill:#ecebe5}.fact-value{font-size:19px;font-weight:700;fill:#ecebe5}.signal{stroke:#f6a821;stroke-width:2}.grid{stroke:#272c2d;stroke-width:1}.panel{fill:#111415;stroke:#303536;stroke-width:1}
</style>
<rect width="1200" height="630" fill="#0a0c0d"/><path d="M0 96H1200M0 594H1200M710 96V594" class="grid"/><path d="M28 28H76M28 28V76M1172 28H1124M1172 28V76M28 602H76M28 602V554M1172 602H1124M1172 602V554" class="signal"/>
<g transform="translate(52 45)"><text x="0" y="0" font-size="34" font-weight="900" fill="#ecebe5">SHD</text><text x="67" y="0" font-size="34" font-weight="900" fill="#f6a821">.build</text><text x="191" y="-2" class="telemetry">BUILD DOSSIER / SOCIAL TRANSMISSION</text></g>
<text x="52" y="139" class="telemetry">AGENT LOADOUT</text><text class="title">${textLines(title, 52, 201, 61)}</text><text x="52" y="294" class="telemetry">AUTHORED BY</text><text x="164" y="294" font-size="18" font-weight="800" fill="#ecebe5">${escapeXml(author)}</text>
<g><rect x="52" y="328" width="622" height="88" class="panel"/><rect x="52" y="328" width="4" height="88" fill="#f6a821"/><text x="76" y="356" class="stat-label">PRIMARY WEAPON</text><text x="76" y="391" class="weapon">${escapeXml(primaryWeapon)}</text></g>
<g><rect x="52" y="430" width="622" height="88" class="panel"/><text x="76" y="458" class="stat-label">SECONDARY WEAPON</text><text x="76" y="493" class="weapon">${escapeXml(secondaryWeapon)}</text></g>
<g transform="translate(746 132)"><text x="0" y="0" class="telemetry">CORE ATTRIBUTES</text><rect x="0" y="24" width="402" height="174" class="panel"/><line x1="134" y1="24" x2="134" y2="198" class="grid"/><line x1="268" y1="24" x2="268" y2="198" class="grid"/><text x="18" y="66" class="stat-label">FIREPOWER</text><text x="18" y="112" class="stat-value">${escapeXml(firepower)}</text><text x="152" y="66" class="stat-label">TOUGHNESS</text><text x="152" y="112" class="stat-value">${escapeXml(toughness)}</text><text x="286" y="66" class="stat-label">ENGINEERING</text><text x="286" y="112" class="stat-value">${escapeXml(engineering)}</text><path d="M18 166H116M152 166H250M286 166H384" class="signal"/></g>
<text x="746" y="374" class="telemetry">SUPPORTING ATTRIBUTES / ${String(facts.length).padStart(2, '0')}</text>${factMarkup}
<g><text x="52" y="566" class="telemetry">DIRECT LINK</text><text x="157" y="566" font-size="15" font-weight="700" fill="#ecebe5">${escapeXml(directUrl)}</text><rect x="972" y="541" width="176" height="36" fill="#15191a" stroke="#f6a821"/><text x="1060" y="564" text-anchor="middle" font-size="13" font-weight="900" letter-spacing="2" fill="#f6a821">${visibilityLabel(input.visibility)}</text></g>
</svg>`;
}
/** Renders a deterministic 1200x630 PNG without loading external resources. */
export async function renderBuildSocialCardPng(
input: BuildSocialCardInput,
): Promise<Buffer> {
return sharp(Buffer.from(buildBuildSocialCardSvg(input)), {
density: 72,
failOn: 'error',
unlimited: false,
})
.png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
.toBuffer();
}
+54 -2
View File
@@ -187,6 +187,7 @@ describe('conditional mechanic persistence adapter', () => {
value_scaling TEXT, unit TEXT, damage_basis TEXT, application_phase TEXT,
combat_timing TEXT, resource_kind TEXT, delivery_mode TEXT,
delivery_duration_seconds REAL, targeting_json TEXT, min_state_value REAL, max_state_value REAL,
probability_percent REAL,
description_slot INTEGER, sort_order INTEGER, created_at TEXT, updated_at TEXT
)`);
@@ -220,6 +221,8 @@ describe('conditional mechanic persistence adapter', () => {
key: 'weapon-kill',
label: 'Weapon kill',
event: 'enemy_killed',
procChancePercent: 29,
requiresAssignedAbilityActive: true,
if: {
kind: 'compare',
left: {
@@ -255,6 +258,7 @@ describe('conditional mechanic persistence adapter', () => {
population: 'allies',
radiusMeters: 40,
},
probabilityPercent: 29,
variants: {
I: { value: 240 },
II: { value: 336 },
@@ -263,12 +267,18 @@ describe('conditional mechanic persistence adapter', () => {
{
key: 'target-detonation',
label: 'Target Detonation',
phase: 'on_exit',
phase: 'after_event',
kind: 'direct_damage',
scope: 'target',
basis: 'flat',
statePool: 'marked-target',
perStack: true,
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
},
variants: {
I: {
value: 240,
@@ -322,6 +332,23 @@ describe('conditional mechanic persistence adapter', () => {
mutations: [],
outcomes: [],
},
{
key: 'tactical-link-assist',
label: 'Tactical Link assist',
event: 'assist',
if: {
kind: 'compare',
left: {
source: 'event',
path: 'values.assignedAbilityActive',
},
operator: 'eq',
right: true,
},
reducer: { kind: 'none' },
mutations: [],
outcomes: [],
},
],
},
);
@@ -366,11 +393,23 @@ describe('conditional mechanic persistence adapter', () => {
population: 'allies',
radiusMeters: 40,
},
probabilityPercent: 29,
variants: {
I: { value: 240 },
II: { value: 336 },
},
});
expect(loaded?.clauses[0].outcomes[1]).toMatchObject({
phase: 'after_event',
kind: 'direct_damage',
scope: 'target',
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
},
});
expect(loaded?.clauses[0].if).toEqual({
kind: 'compare',
left: {
@@ -381,6 +420,10 @@ describe('conditional mechanic persistence adapter', () => {
operator: 'lt',
right: 75,
});
expect(loaded?.clauses[0]).toMatchObject({
procChancePercent: 29,
requiresAssignedAbilityActive: true,
});
expect(loaded?.pools[0]).toMatchObject({
key: 'marked-target',
stateKind: 'target_mark',
@@ -399,7 +442,7 @@ describe('conditional mechanic persistence adapter', () => {
},
]);
expect(loaded?.clauses[0].outcomes[1]).toMatchObject({
phase: 'on_exit',
phase: 'after_event',
kind: 'direct_damage',
scope: 'target',
statePool: 'marked-target',
@@ -430,5 +473,14 @@ describe('conditional mechanic persistence adapter', () => {
right: true,
},
});
expect(loaded?.clauses[2]).toMatchObject({
event: 'assist',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.assignedAbilityActive' },
operator: 'eq',
right: true,
},
});
});
});
+32 -3
View File
@@ -90,6 +90,7 @@ const outcomeMetadataColumns = [
'delivery_mode',
'delivery_duration_seconds',
'targeting_json',
'probability_percent',
'state_pool_id',
'min_state_value',
'max_state_value',
@@ -205,6 +206,10 @@ export function conditionalRuleDraftFromRows(
.filter((row) => row.rule_id === rule.id && Number(row.active ?? 1) === 1)
.sort((left, right) => Number(left.sort_order) - Number(right.sort_order))
.map((row): ConditionalMechanicClauseDraft => {
const parameters = parseJson<Record<string, unknown>>(
row.recipe_parameters_json,
{},
);
const grouped = new Map<string, OutcomeRow[]>();
for (const outcome of rowsByClause.get(String(row.id)) ?? []) {
const key = String(outcome.effect_key);
@@ -268,6 +273,10 @@ export function conditionalRuleDraftFromRows(
...(first.targeting_json
? { targeting: parseJson(first.targeting_json, undefined) }
: {}),
...(first.probability_percent === null ||
first.probability_percent === undefined
? {}
: { probabilityPercent: Number(first.probability_percent) }),
...(first.stat_attribute_id
? { attributeId: String(first.stat_attribute_id) }
: {}),
@@ -329,6 +338,15 @@ export function conditionalRuleDraftFromRows(
),
}
: {}),
...(typeof parameters.procChancePercent === 'number'
? { procChancePercent: parameters.procChancePercent }
: {}),
...(typeof parameters.requiresAssignedAbilityActive === 'boolean'
? {
requiresAssignedAbilityActive:
parameters.requiresAssignedAbilityActive,
}
: {}),
...(row.cooldown_seconds === null || row.cooldown_seconds === undefined
? {}
: { cooldownSeconds: Number(row.cooldown_seconds) }),
@@ -495,7 +513,17 @@ export async function saveConditionalRuleInTransaction(
clause.label || clause.key,
clause.event,
clause.recipeKey ?? null,
'{}',
JSON.stringify({
...(clause.procChancePercent === undefined
? {}
: { procChancePercent: clause.procChancePercent }),
...(clause.requiresAssignedAbilityActive === undefined
? {}
: {
requiresAssignedAbilityActive:
clause.requiresAssignedAbilityActive,
}),
}),
JSON.stringify(clause.if ?? { kind: 'true' }),
JSON.stringify(clause.reducer),
clause.weight === undefined ? null : JSON.stringify(clause.weight),
@@ -518,9 +546,9 @@ export async function saveConditionalRuleInTransaction(
application_kind,effect_scope,stat_attribute_id,value,value_expression_json,
value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing,
resource_kind,delivery_mode,delivery_duration_seconds,
targeting_json,min_state_value,max_state_value,description_slot,sort_order,
targeting_json,probability_percent,min_state_value,max_state_value,description_slot,sort_order,
created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
randomUUID(),
ruleId,
@@ -552,6 +580,7 @@ export async function saveConditionalRuleInTransaction(
outcome.targeting === undefined
? null
: JSON.stringify(outcome.targeting),
outcome.probabilityPercent ?? null,
outcome.minStacks ?? null,
outcome.maxStacks ?? null,
outcome.descriptionSlot ?? null,
+20 -3
View File
@@ -37,14 +37,31 @@ const proposalResultKeys: Partial<
saveAugmentProfile: { saved: 'augmentProfileSaved' },
},
weapons: { save: { saved: 'weaponSaved', mode: 'weaponMode' } },
weaponmods: { save: { saved: 'weaponModSaved', mode: 'weaponModMode' } },
gearmods: { save: { saved: 'gearModSaved', mode: 'gearModMode' } },
weaponmods: {
save: { saved: 'weaponModSaved', mode: 'weaponModMode' },
saveSlotIcon: { saved: 'weaponModSlotIconSaved' },
saveSecondaryPool: {
saved: 'weaponModSecondaryPoolSaved',
mode: 'weaponModSecondaryPoolMode',
},
},
gearmods: {
save: { saved: 'gearModSaved', mode: 'gearModMode' },
saveSecondaryPool: {
saved: 'gearModSecondaryPoolSaved',
mode: 'gearModSecondaryPoolMode',
},
},
talents: { saveTalent: { saved: 'talentSaved', mode: 'talentMode' } },
protocols: {
save: { saved: 'protocolSaved', mode: 'protocolMode' },
saveProfile: { saved: 'profileSaved' },
},
skillchips: { save: { saved: 'skillChipSaved', mode: 'skillChipMode' } },
skillchips: {
save: { saved: 'skillChipSaved', mode: 'skillChipMode' },
savePrimaryProfile: { saved: 'skillChipPrimaryProfileSaved' },
saveSecondaryPool: { saved: 'skillChipSecondaryPoolSaved' },
},
specializations: {
saveProgression: { saved: 'progressionSaved' },
saveSpecialization: {
+12 -1
View File
@@ -7,6 +7,7 @@ import {
BackgroundRemovalError,
removeBackgroundPreview,
} from './remove-background';
import { weaponModSlotDefinitionById } from '$lib/weapons/mod-slots';
export const GAME_ICON_TYPES = [
'talent',
@@ -16,6 +17,7 @@ export const GAME_ICON_TYPES = [
'specialization',
'specialization_ability',
'specialization_node',
'weapon_mod_slot',
] as const;
export type GameDataIconType = (typeof GAME_ICON_TYPES)[number];
export const MAX_GAME_ICON_BYTES = 2 * 1024 * 1024;
@@ -23,7 +25,7 @@ export const MAX_GAME_ICON_PIXELS = 12_000_000;
export const GAME_ICON_SIZE = 256;
const ENTITY_ID_PATTERN = /^[0-9a-f-]{36}$/i;
const STORAGE_KEY_PATTERN =
/^(talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
/^(talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i;
const LIBRARY_CATEGORY: Record<GameDataIconType, string> = {
talent: 'Talents',
brand_set: 'Brands',
@@ -32,6 +34,7 @@ const LIBRARY_CATEGORY: Record<GameDataIconType, string> = {
specialization: 'Specializations',
specialization_ability: 'Specializations',
specialization_node: 'Specializations',
weapon_mod_slot: 'Weapon Mods',
};
const LIBRARY_LABEL: Record<GameDataIconType, string> = {
talent: 'Talent',
@@ -41,6 +44,7 @@ const LIBRARY_LABEL: Record<GameDataIconType, string> = {
specialization: 'Specialization',
specialization_ability: 'Specialization Ability',
specialization_node: 'Specialization Tree Node',
weapon_mod_slot: 'Weapon Mod Slot',
};
export class GameDataIconValidationError extends Error {}
@@ -255,6 +259,13 @@ async function libraryMetadata(
],
};
}
case 'weapon_mod_slot': {
const definition = weaponModSlotDefinitionById(entityId);
return {
name: definition ? `${definition.name} Slot` : null,
tags: ['weapon mod', 'slot', definition?.name],
};
}
}
return { name: null, tags: [] };
}
+167 -2
View File
@@ -1,17 +1,24 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import sharp from 'sharp';
const { rmMock } = vi.hoisted(() => ({ rmMock: vi.fn() }));
const { mkdirMock, rmMock, writeFileMock } = vi.hoisted(() => ({
mkdirMock: vi.fn(),
rmMock: vi.fn(),
writeFileMock: vi.fn(),
}));
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof import('node:fs/promises')>()),
mkdir: mkdirMock,
rm: rmMock,
writeFile: writeFileMock,
}));
import { GAME_ICON_TYPES } from './game-data-icons';
import {
GAME_DATA_RENDER_TYPES,
ManagedImageDeletionError,
applyGameDataRender,
deleteManagedImage,
processManagedImage,
} from './managed-images';
@@ -26,6 +33,16 @@ class FakeDatabase {
prepare(sql: string) {
return Promise.resolve({
get: async (...args: unknown[]) => {
if (
sql.startsWith('SELECT * FROM managed_images') &&
sql.includes('source_entity_type')
) {
return [...this.images.values()].find(
(row) =>
row.source_entity_type === args[0] &&
row.source_entity_id === args[1],
);
}
if (sql.startsWith('SELECT * FROM managed_images'))
return this.images.get(String(args[0]));
if (sql.startsWith('SELECT * FROM game_data_icons')) {
@@ -42,7 +59,64 @@ class FakeDatabase {
}
if (sql.startsWith('DELETE FROM managed_images')) {
const row = this.images.get(String(args[0]));
if (row?.storage_key === args[1]) this.images.delete(String(args[0]));
if (!sql.includes('storage_key') || row?.storage_key === args[1])
this.images.delete(String(args[0]));
return;
}
if (sql.startsWith('UPDATE managed_images SET')) {
const row = this.images.get(String(args[7]));
if (!row) throw new Error('Missing managed image for update');
this.images.set(String(args[7]), {
...row,
category: args[0],
tags_json: args[1],
is_icon: 0,
force_white: 0,
original_filename: args[2],
byte_size: args[3],
width: args[4],
height: args[5],
updated_at: args[6],
});
return;
}
if (sql.startsWith('INSERT INTO managed_images')) {
const [
id,
name,
category,
tagsJson,
isIcon,
forceWhite,
storageKey,
originalFilename,
byteSize,
width,
height,
sourceType,
sourceEntityId,
createdBy,
createdAt,
updatedAt,
] = args;
this.images.set(String(id), {
id,
name,
category,
tags_json: tagsJson,
is_icon: isIcon,
force_white: forceWhite,
storage_key: storageKey,
original_filename: originalFilename,
byte_size: byteSize,
width,
height,
source_entity_type: sourceType,
source_entity_id: sourceEntityId,
created_by: createdBy,
created_at: createdAt,
updated_at: updatedAt,
});
return;
}
throw new Error(`Unexpected run: ${sql}`);
@@ -210,3 +284,94 @@ describe('processManagedImage', () => {
expect(metadata.height).toBe(1000);
});
});
describe('applyGameDataRender', () => {
beforeEach(() => {
mkdirMock.mockReset().mockResolvedValue(undefined);
rmMock.mockReset().mockResolvedValue(undefined);
writeFileMock.mockReset().mockResolvedValue(undefined);
});
it.each(GAME_DATA_RENDER_TYPES)(
'persists and returns authoritative %s render metadata',
async (type) => {
const database = new FakeDatabase();
const result = await applyGameDataRender(
database as never,
type,
sourceId,
'Test Entity',
{
file: new File(['source'], 'source.png', { type: 'image/png' }),
remove: false,
processed: {
output: Buffer.from('processed-render'),
width: 1000,
height: 1000,
},
},
'operator-1',
);
expect(result).toMatchObject({
original_filename: 'source.png',
byte_size: Buffer.byteLength('processed-render'),
width: 1000,
height: 1000,
source_entity_type: `${type}_render`,
source_entity_id: sourceId,
created_by: 'operator-1',
created_at: expect.any(String),
updated_at: expect.any(String),
});
expect(writeFileMock).toHaveBeenCalledOnce();
},
);
it('returns refreshed replacement metadata without rewriting creator provenance', async () => {
const database = new FakeDatabase();
database.images.set(imageId, {
id: imageId,
name: 'Ability render',
category: 'Specializations',
tags_json: '[]',
is_icon: 0,
force_white: 0,
storage_key: `library-${imageId}.webp`,
original_filename: 'old.png',
byte_size: 3,
width: 100,
height: 100,
source_entity_type: 'specialization_ability_render',
source_entity_id: sourceId,
created_by: 'original-operator',
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
});
const result = await applyGameDataRender(
database as never,
'specialization_ability',
sourceId,
'Ability',
{
file: new File(['new'], 'new.png', { type: 'image/png' }),
remove: false,
processed: {
output: Buffer.from('replacement'),
width: 1000,
height: 1000,
},
},
'replacement-operator',
);
expect(result).toMatchObject({
id: imageId,
original_filename: 'new.png',
byte_size: Buffer.byteLength('replacement'),
created_by: 'original-operator',
created_at: '2026-01-01T00:00:00.000Z',
});
});
});
+12 -26
View File
@@ -41,7 +41,7 @@ export type GameDataRenderType = (typeof GAME_DATA_RENDER_TYPES)[number];
const MAX_PIXELS = 24_000_000;
const ID_PATTERN = /^[0-9a-f-]{36}$/i;
const KEY_PATTERN =
/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i;
export class ManagedImageValidationError extends Error {}
export class ManagedImageDeletionError extends Error {}
@@ -250,7 +250,7 @@ export async function applyGameDataRender(
);
await (
await database.prepare(`UPDATE managed_images SET category=?,tags_json=?,is_icon=0,force_white=0,
original_filename=?,byte_size=?,width=?,height=?,created_by=?,updated_at=? WHERE id=?`)
original_filename=?,byte_size=?,width=?,height=?,updated_at=? WHERE id=?`)
).run(
category[type],
tags,
@@ -258,19 +258,14 @@ export async function applyGameDataRender(
processed.output.length,
processed.width,
processed.height,
actorId,
now,
existing.id,
);
return {
...existing,
category: category[type],
tags_json: tags,
byte_size: processed.output.length,
width: processed.width,
height: processed.height,
updated_at: now,
};
return (
(await (
await database.prepare('SELECT * FROM managed_images WHERE id=?')
).get(existing.id)) ?? null
);
}
const id = randomUUID();
const storageKey = await saveManagedImageFile(id, processed.output);
@@ -298,20 +293,11 @@ export async function applyGameDataRender(
now,
now,
);
return {
id,
name,
category: category[type],
tags_json: tags,
is_icon: 0,
storage_key: storageKey,
byte_size: processed.output.length,
width: processed.width,
height: processed.height,
source_entity_type: sourceType,
source_entity_id: entityId,
updated_at: now,
};
return (
(await (
await database.prepare('SELECT * FROM managed_images WHERE id=?')
).get(id)) ?? null
);
}
export async function deleteManagedImage(database: Database, id: string) {
+100
View File
@@ -0,0 +1,100 @@
import type { Database } from '@tursodatabase/database';
import {
MOD_SECONDARY_QUALITIES,
normalizeModSecondaryPool,
type ModSecondaryRange,
} from '$lib/mods/secondary-pool';
export type ModSecondaryPoolKind = 'weapon' | 'gear';
function tables(kind: ModSecondaryPoolKind) {
return {
options: `${kind}_mod_secondary_options`,
values: `${kind}_mod_secondary_quality_values`,
};
}
export async function getModSecondaryPool(
database: Database,
kind: ModSecondaryPoolKind,
): Promise<ModSecondaryRange[]> {
const table = tables(kind);
const [options, values] = await Promise.all([
(
await database.prepare(`SELECT stat_attribute_id FROM ${table.options}
ORDER BY stat_attribute_id`)
).all(),
(
await database.prepare(`SELECT * FROM ${table.values}
ORDER BY stat_attribute_id,quality`)
).all(),
]);
const pool = options.map((option) => {
const optionValues = values.filter(
(value) => value.stat_attribute_id === option.stat_attribute_id,
);
if (
optionValues.length !== MOD_SECONDARY_QUALITIES.length ||
MOD_SECONDARY_QUALITIES.some(
(quality) => !optionValues.some((value) => value.quality === quality),
)
) {
throw new Error(
`${kind} Mod secondary Attribute ${String(option.stat_attribute_id)} does not have a complete quality profile.`,
);
}
return {
statId: String(option.stat_attribute_id),
values: Object.fromEntries(
optionValues.map((value) => [
String(value.quality),
{
level1Min: Number(value.level_1_min),
level1Max: Number(value.level_1_max),
level20Min: Number(value.level_20_min),
level20Max: Number(value.level_20_max),
},
]),
) as ModSecondaryRange['values'],
};
});
if (!pool.length) return [];
const normalized = normalizeModSecondaryPool(pool);
if (normalized.error) {
throw new Error(`${kind} Mod secondary pool: ${normalized.error}`);
}
return normalized.pool;
}
export async function replaceModSecondaryPool(
database: Database,
kind: ModSecondaryPoolKind,
pool: ModSecondaryRange[],
) {
const table = tables(kind);
await database.exec(`DELETE FROM ${table.values}`);
await database.exec(`DELETE FROM ${table.options}`);
for (const range of pool) {
await (
await database.prepare(
`INSERT INTO ${table.options}(stat_attribute_id) VALUES(?)`,
)
).run(range.statId);
for (const quality of MOD_SECONDARY_QUALITIES) {
const values = range.values[quality];
await (
await database.prepare(`INSERT INTO ${table.values}
(stat_attribute_id,quality,level_1_min,level_1_max,level_20_min,level_20_max)
VALUES(?,?,?,?,?,?)`)
).run(
range.statId,
quality,
values.level1Min,
values.level1Max,
values.level20Min,
values.level20Max,
);
}
}
}
@@ -0,0 +1,96 @@
import { connect } from '@tursodatabase/database';
import { afterEach, describe, expect, it } from 'vitest';
import {
getOsProtocolProfileAttributeIds,
type OsProtocolAttributeType,
} from './os-protocol-profiles';
const databases: Array<Awaited<ReturnType<typeof connect>>> = [];
afterEach(async () => {
await Promise.all(databases.splice(0).map((database) => database.close()));
});
async function databaseWithAttributes() {
const database = await connect(':memory:');
databases.push(database);
await database.exec(`CREATE TABLE stat_attributes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
unit TEXT NOT NULL,
active INTEGER NOT NULL
)`);
for (const [id, name, unit] of [
['firepower-flat', 'Firepower', ''],
['firepower-percent', 'Firepower', '%'],
['toughness-flat', 'Toughness', ''],
['toughness-percent', 'Toughness', '%'],
['engineering-flat', 'Engineering', ''],
['weapon-damage-flat', 'Weapon Damage', ''],
['weapon-damage-percent', 'Weapon Damage', '%'],
['skill-damage-percent', 'Skill Damage', '%'],
['health-flat', 'Health', ''],
]) {
await (
await database.prepare(
'INSERT INTO stat_attributes(id,name,unit,active) VALUES(?,?,?,1)',
)
).run(id, name, unit);
}
return database;
}
describe('OS Protocol profile Attributes', () => {
it.each([
[
'Firepower',
{
Core: 'firepower-flat',
Damage: 'weapon-damage-flat',
Health: 'health-flat',
},
],
[
'Toughness',
{
Core: 'toughness-flat',
Damage: 'weapon-damage-flat',
Health: 'health-flat',
},
],
[
'Engineering',
{
Core: 'engineering-flat',
Damage: 'skill-damage-percent',
Health: 'health-flat',
},
],
] as const)('resolves the exact %s role rows', async (type, expected) => {
const database = await databaseWithAttributes();
await expect(
getOsProtocolProfileAttributeIds(
database,
type as OsProtocolAttributeType,
),
).resolves.toEqual(expected);
});
it('reports a missing required role without selecting a similarly named row', async () => {
const database = await databaseWithAttributes();
await (
await database.prepare(
"DELETE FROM stat_attributes WHERE id='weapon-damage-flat'",
)
).run();
await expect(
getOsProtocolProfileAttributeIds(database, 'Firepower'),
).resolves.toEqual({
Core: 'firepower-flat',
Damage: null,
Health: 'health-flat',
});
});
});
+48
View File
@@ -0,0 +1,48 @@
import type { Database } from '@tursodatabase/database';
export const OS_PROTOCOL_PROFILE_ATTRIBUTE_SPECS = {
Firepower: {
Core: { name: 'Firepower', unit: '' },
Damage: { name: 'Weapon Damage', unit: '' },
Health: { name: 'Health', unit: '' },
},
Toughness: {
Core: { name: 'Toughness', unit: '' },
Damage: { name: 'Weapon Damage', unit: '' },
Health: { name: 'Health', unit: '' },
},
Engineering: {
Core: { name: 'Engineering', unit: '' },
Damage: { name: 'Skill Damage', unit: '%' },
Health: { name: 'Health', unit: '' },
},
} as const;
export type OsProtocolAttributeType =
keyof typeof OS_PROTOCOL_PROFILE_ATTRIBUTE_SPECS;
export type OsProtocolSecondaryRole = 'Core' | 'Damage' | 'Health';
export async function getOsProtocolProfileAttributeIds(
database: Database,
attributeType: OsProtocolAttributeType,
) {
const specs = OS_PROTOCOL_PROFILE_ATTRIBUTE_SPECS[attributeType];
const entries = await Promise.all(
(
Object.entries(specs) as Array<
[OsProtocolSecondaryRole, { name: string; unit: string }]
>
).map(async ([role, spec]) => {
const row = await (
await database.prepare(
'SELECT id FROM stat_attributes WHERE active=1 AND name=? COLLATE NOCASE AND unit=?',
)
).get(spec.name, spec.unit);
return [role, row ? String(row.id) : null] as const;
}),
);
return Object.fromEntries(entries) as Record<
OsProtocolSecondaryRole,
string | null
>;
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { publicOrigin } from './public-origin';
describe('publicOrigin', () => {
it('uses the configured public origin instead of a request host', () => {
expect(
publicOrigin('https://host-header.invalid', 'https://shd.build/app/path'),
).toBe('https://shd.build');
});
it('uses the request origin when APP_ORIGIN is not configured', () => {
expect(publicOrigin('http://localhost:5173', '')).toBe(
'http://localhost:5173',
);
});
it('rejects non-HTTP origins', () => {
expect(() =>
publicOrigin('https://shd.build', 'javascript:alert(1)'),
).toThrow('APP_ORIGIN must use HTTP or HTTPS.');
});
});
+13
View File
@@ -0,0 +1,13 @@
import { env } from '$env/dynamic/private';
export function publicOrigin(
fallback: string,
configuredOrigin: string | undefined = env.APP_ORIGIN,
): string {
const value = configuredOrigin?.trim() || fallback;
const url = new URL(value);
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new Error('APP_ORIGIN must use HTTP or HTTPS.');
}
return url.origin;
}
-388
View File
@@ -1,388 +0,0 @@
import type { Database } from '@tursodatabase/database';
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
export async function loadSharedBuildCombatCatalog(database: Database) {
const [
attributes,
gear,
gearAugments,
brands,
brandEffects,
weapons,
weaponBonuses,
talents,
talentTiers,
talentEffects,
talentTemplates,
conditionalRules,
conditionalPools,
conditionalClauses,
conditionalOutcomes,
chipSets,
chipTwoPiece,
chipTalents,
chipTalentEffects,
protocols,
protocolPrimary,
protocolProfiles,
protocolSecondary,
protocolTalents,
protocolTalentEffects,
specializations,
specializationFocuses,
specializationAbilities,
specializationAbilityAttacks,
focusAbilities,
specializationNodes,
specializationRanks,
specializationRankEffects,
specializationCombatEffects,
specializationRankCombatEffects,
specializationCombatModifiers,
activeCalculationRevision,
calculationStages,
calculationProfiles,
skillCalculationProfiles,
] = await Promise.all([
(
await database.prepare(
'SELECT * FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM gear WHERE active=1 ORDER BY slot,name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot',
)
).all(),
(
await database.prepare(
'SELECT * FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM brand_set_effects ORDER BY pieces_required',
)
).all(),
(
await database.prepare(
'SELECT * FROM weapons WHERE active=1 ORDER BY category,name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM weapon_category_attribute_bonuses ORDER BY weapon_category,sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM talents WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(await database.prepare('SELECT * FROM talent_tiers ORDER BY tier')).all(),
(await database.prepare('SELECT * FROM talent_tier_effects')).all(),
(
await database.prepare('SELECT * FROM talent_description_templates')
).all(),
(
await database.prepare(
'SELECT * FROM conditional_effect_rules WHERE configured=1',
)
).all(),
(
await database.prepare(
'SELECT * FROM conditional_effect_state_pools ORDER BY rule_id,sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM conditional_effect_clauses WHERE active=1 ORDER BY rule_id,sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM conditional_effect_outcomes ORDER BY rule_id,variant_key,outcome_key,sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
).all(),
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
(
await database.prepare(
'SELECT * FROM skill_chip_talent_effects ORDER BY sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM os_protocols WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(),
(
await database.prepare('SELECT * FROM os_protocol_secondary_profiles')
).all(),
(
await database.prepare(
'SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role',
)
).all(),
(await database.prepare('SELECT * FROM os_protocol_talents')).all(),
(
await database.prepare(
'SELECT * FROM os_protocol_talent_effects ORDER BY sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM specializations WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_focuses WHERE active=1 ORDER BY specialization_id,display_order,name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_abilities WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE',
)
).all(),
(
await database.prepare('SELECT * FROM specialization_ability_attacks')
).all(),
(
await database.prepare(
'SELECT * FROM specialization_focus_abilities ORDER BY focus_id,ability_slot',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_tree_nodes WHERE active=1 ORDER BY focus_id,row_number,column_number',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_tree_node_ranks ORDER BY node_id,rank',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_combat_effects WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_tree_rank_combat_effects ORDER BY node_id,rank,effect_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_tree_rank_combat_modifiers ORDER BY rank_combat_effect_id,modifier_order',
)
).all(),
(
await database.prepare(
"SELECT * FROM calculation_revisions WHERE status='active' ORDER BY version DESC LIMIT 1",
)
).get(),
(
await database.prepare(`SELECT stage.* FROM calculation_stages stage
JOIN calculation_revisions revision ON revision.id=stage.revision_id
WHERE revision.status='active' ORDER BY stage.display_order`)
).all(),
(
await database.prepare(`SELECT profile.* FROM attribute_calculation_profiles profile
JOIN calculation_revisions revision ON revision.id=profile.revision_id
WHERE revision.status='active' ORDER BY profile.stage_key,profile.stat_attribute_id`)
).all(),
(
await database.prepare(`SELECT profile.* FROM skill_attribute_calculation_profiles profile
JOIN calculation_revisions revision ON revision.id=profile.revision_id
WHERE revision.status='active' ORDER BY profile.stage_key,profile.stat_attribute_id`)
).all(),
]);
const hydrateRule = (sourceType: string, sourceId: unknown) => {
const rule = conditionalRules.find(
(item) => item.source_type === sourceType && item.source_id === sourceId,
);
return rule
? {
rule,
statePools: conditionalPools.filter(
(pool) => pool.rule_id === rule.id,
),
clauses: conditionalClauses.filter(
(clause) => clause.rule_id === rule.id,
),
outcomes: conditionalOutcomes.filter(
(outcome) => outcome.rule_id === rule.id,
),
triggers: [],
}
: null;
};
const configuredTalents = talents.map((talent) => ({
...talent,
template:
talentTemplates.find((item) => item.talent_id === talent.id)?.template ??
'',
tiers: talentTiers
.filter((tier) => tier.talent_id === talent.id)
.map((tier) => ({
...tier,
effects: talentEffects.filter(
(effect) => effect.talent_tier_id === tier.id,
),
})),
rule: hydrateRule('talent', talent.id),
}));
return {
attributes,
gear,
gearAugments,
brands: brands.map((brand) => ({
...brand,
effects: brandEffects.filter(
(effect) => effect.brand_set_id === brand.id,
),
})),
weapons: attachWeaponCategoryBonuses(weapons, weaponBonuses),
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,
),
rule: hydrateRule('skill_chip_talent', 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,
),
rule: hydrateRule('os_protocol_talent', 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) => {
const ability = specializationAbilities.find(
(entry) => entry.id === link.ability_id,
);
return ability
? {
...ability,
attack:
specializationAbilityAttacks.find(
(attack) => attack.ability_id === ability.id,
) ?? null,
}
: null;
})
.filter(Boolean);
const nodes = specializationNodes
.filter((node) => node.focus_id === focus.id)
.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,
),
rule: hydrateRule('specialization_tree_rank', effect.id),
})),
})),
}));
return { ...focus, abilities, nodes };
}),
})),
calculation: {
revision: activeCalculationRevision ?? null,
stages: calculationStages,
profiles: calculationProfiles,
skillProfiles: skillCalculationProfiles,
},
};
}
+306
View File
@@ -0,0 +1,306 @@
import type { Database } from '@tursodatabase/database';
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
import { hydrateSkillChipProfileRows } from '$lib/skill-chips/profiles';
export async function loadSharedBuildSummaryCatalog(database: Database) {
const [
attributes,
gear,
gearAugments,
brands,
brandEffects,
weapons,
weaponBonuses,
talents,
talentTiers,
talentEffects,
talentTemplates,
chipSets,
abilitySkillChipLinks,
chipTwoPiece,
chipPrimaryOptions,
chipPrimaryQualityValues,
chipSecondaryOptions,
chipSecondaryQualityValues,
chipTalents,
chipTalentEffects,
protocols,
protocolPrimary,
protocolProfiles,
protocolSecondary,
protocolTalents,
protocolTalentEffects,
specializations,
specializationFocuses,
specializationAbilities,
focusAbilities,
specializationNodes,
specializationRanks,
specializationRankEffects,
] = await Promise.all([
(
await database.prepare(
'SELECT * FROM stat_attributes ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM gear ORDER BY slot,name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot',
)
).all(),
(
await database.prepare(
'SELECT * FROM brand_sets ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM brand_set_effects ORDER BY pieces_required',
)
).all(),
(
await database.prepare(
'SELECT * FROM weapons ORDER BY category,name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM weapon_category_attribute_bonuses ORDER BY weapon_category,sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM talents ORDER BY name COLLATE NOCASE',
)
).all(),
(await database.prepare('SELECT * FROM talent_tiers ORDER BY tier')).all(),
(await database.prepare('SELECT * FROM talent_tier_effects')).all(),
(
await database.prepare('SELECT * FROM talent_description_templates')
).all(),
(
await database.prepare(
'SELECT * FROM skill_chip_sets ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
`SELECT link.ability_id,link.compatible_ability_id
FROM specialization_ability_skill_chip_links link
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
JOIN specialization_abilities compatible ON compatible.id=link.compatible_ability_id AND compatible.active=1`,
)
).all(),
(
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
).all(),
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
(
await database.prepare('SELECT * FROM skill_chip_primary_quality_values')
).all(),
(
await database.prepare('SELECT * FROM skill_chip_secondary_options')
).all(),
(
await database.prepare(
'SELECT * FROM skill_chip_secondary_quality_values',
)
).all(),
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
(
await database.prepare(
'SELECT * FROM skill_chip_talent_effects ORDER BY sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM os_protocols ORDER BY name COLLATE NOCASE',
)
).all(),
(await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(),
(
await database.prepare('SELECT * FROM os_protocol_secondary_profiles')
).all(),
(
await database.prepare(
'SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role',
)
).all(),
(await database.prepare('SELECT * FROM os_protocol_talents')).all(),
(
await database.prepare(
'SELECT * FROM os_protocol_talent_effects ORDER BY sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM specializations ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_focuses ORDER BY specialization_id,display_order,name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_abilities ORDER BY specialization_id,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 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(),
]);
return {
attributes,
gear,
gearAugments,
brands: brands.map((brand) => ({
...brand,
effects: brandEffects.filter(
(effect) => effect.brand_set_id === brand.id,
),
})),
weapons: attachWeaponCategoryBonuses(weapons, weaponBonuses),
talents: talents.map((talent) => ({
...talent,
template:
talentTemplates.find((item) => item.talent_id === talent.id)
?.template ?? '',
tiers: talentTiers
.filter((tier) => tier.talent_id === talent.id)
.map((tier) => ({
...tier,
effects: talentEffects.filter(
(effect) => effect.talent_tier_id === tier.id,
),
})),
})),
skillChipProfile: {
primaryOptions: hydrateSkillChipProfileRows(
chipPrimaryOptions,
chipPrimaryQualityValues,
),
secondaryOptions: hydrateSkillChipProfileRows(
chipSecondaryOptions,
chipSecondaryQualityValues,
),
},
chipSets: chipSets.map((set) => {
const talent = chipTalents.find(
(item) => item.skill_chip_set_id === set.id,
);
return {
...set,
compatibleAbilityIds: [
String(set.specialization_ability_id),
...abilitySkillChipLinks.flatMap((link) =>
link.ability_id === set.specialization_ability_id
? [String(link.compatible_ability_id)]
: link.compatible_ability_id === set.specialization_ability_id
? [String(link.ability_id)]
: [],
),
],
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) => ({
...focus,
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),
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),
),
})),
})),
})),
})),
};
}
+100
View File
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createDraft } from '$lib/builder/loadout';
const mocks = vi.hoisted(() => ({
row: null as Record<string, unknown> | null,
loadCatalog: vi.fn(),
}));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => ({
prepare: vi.fn(async (sql: string) => ({
get: vi.fn(async () => (sql.includes('FROM builds') ? mocks.row : null)),
all: vi.fn(async () => []),
})),
})),
}));
vi.mock('$lib/server/shared-build-summary', () => ({
loadSharedBuildSummaryCatalog: mocks.loadCatalog,
}));
import { loadSharedBuildProjection } from './shared-build';
function buildRow(visibility: 'private' | 'unlisted' | 'public') {
return {
id: 'build-id',
owner_id: 'owner-id',
title: 'Field Medic',
slug: 'field-medic-12345678',
visibility,
author: 'Aewyn',
profile_is_public: 1,
loadout_json: JSON.stringify(createDraft()),
updated_at: '2026-08-02T00:00:00.000Z',
};
}
describe('shared Build access policy', () => {
beforeEach(() => {
mocks.row = null;
mocks.loadCatalog.mockReset().mockResolvedValue({
attributes: [],
gear: [],
gearAugments: [],
brands: [],
weapons: [],
talents: [],
chipSets: [],
protocols: [],
specializations: [],
});
});
it.each(['public', 'unlisted'] as const)(
'permits %s Builds in public previews',
async (visibility) => {
mocks.row = buildRow(visibility);
const result = await loadSharedBuildProjection('field-medic-12345678', {
kind: 'public-preview',
});
expect(result?.build.visibility).toBe(visibility);
expect(result?.build.owner).toBe(false);
},
);
it('does not expose private Builds to public previews or other viewers', async () => {
mocks.row = buildRow('private');
await expect(
loadSharedBuildProjection('field-medic-12345678', {
kind: 'public-preview',
}),
).resolves.toBeNull();
await expect(
loadSharedBuildProjection('field-medic-12345678', {
kind: 'viewer-page',
viewerId: 'another-agent',
}),
).resolves.toBeNull();
expect(mocks.loadCatalog).not.toHaveBeenCalled();
});
it('permits the owner to view a private Build page', async () => {
mocks.row = buildRow('private');
const result = await loadSharedBuildProjection('field-medic-12345678', {
kind: 'viewer-page',
viewerId: 'owner-id',
});
expect(result?.build.owner).toBe(true);
expect(result?.build.visibility).toBe('private');
});
it('returns null for malformed loadout snapshots', async () => {
mocks.row = { ...buildRow('public'), loadout_json: '{invalid' };
await expect(
loadSharedBuildProjection('field-medic-12345678', {
kind: 'public-preview',
}),
).resolves.toBeNull();
expect(mocks.loadCatalog).not.toHaveBeenCalled();
});
});
+104
View File
@@ -0,0 +1,104 @@
import { avatarUrl } from '$lib/avatar';
import { parseLoadoutDraft } from '$lib/builder/loadout';
import { summarizeLoadout } from '$lib/builder/loadout-summary';
import { getDatabase } from '$lib/server/database';
import { loadSharedBuildSummaryCatalog } from '$lib/server/shared-build-summary';
export type SharedBuildAccessPolicy =
{ kind: 'viewer-page'; viewerId: string | null } | { kind: 'public-preview' };
type BuildVisibility = 'public' | 'unlisted' | 'private';
export async function loadSharedBuildProjection(
slug: string,
access: SharedBuildAccessPolicy,
) {
const database = await getDatabase();
const build = await (
await database.prepare(`
SELECT builds.*,COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author,
user_profiles.public_slug,
COALESCE(user_profiles.is_public,1) AS profile_is_public,deleted_accounts.user_id AS deleted_user_id,
user_avatars.source AS avatar_source,user_avatars.value AS avatar_value,
user_avatars.updated_at AS avatar_updated_at
FROM builds
JOIN users ON users.id=builds.owner_id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
LEFT JOIN user_avatars ON user_avatars.user_id=users.id
WHERE builds.slug=?
`)
).get(slug);
if (!build) return null;
const ownerId = String(build.owner_id);
const visibility = String(build.visibility) as BuildVisibility;
const viewerId = access.kind === 'viewer-page' ? access.viewerId : null;
const accessible =
access.kind === 'public-preview'
? visibility === 'public' || visibility === 'unlisted'
: visibility === 'public' ||
visibility === 'unlisted' ||
(visibility === 'private' && ownerId === viewerId);
if (!accessible) return null;
let loadout;
try {
loadout = parseLoadoutDraft(JSON.parse(String(build.loadout_json)));
} catch {
return null;
}
if (!loadout) return null;
const [gear, weapons, chips, protocols, brands, summaryCatalog] =
await Promise.all([
(
await database.prepare(
'SELECT id,name,slot,tier,base_armor_min FROM gear',
)
).all(),
(
await database.prepare(
'SELECT id,name,category,is_exotic,rpm,magazine,reload_speed,damage_type,exotic_talent_id FROM weapons',
)
).all(),
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
(
await database.prepare('SELECT id,name,quality FROM os_protocols')
).all(),
(await database.prepare('SELECT id,name FROM brand_sets')).all(),
loadSharedBuildSummaryCatalog(database),
]);
const summary = summarizeLoadout(loadout, summaryCatalog);
return {
build: {
id: String(build.id),
title: String(build.title),
slug: String(build.slug),
visibility,
author: String(build.author),
ownerId,
authorPublicSlug: build.public_slug ? String(build.public_slug) : null,
profileAvailable:
Boolean(build.profile_is_public) && !build.deleted_user_id,
authorAvatarUrl: avatarUrl(
build.avatar_source,
build.avatar_value,
ownerId,
build.avatar_updated_at,
),
owner: ownerId === viewerId,
updatedAt: String(build.updated_at),
},
loadout,
gear,
weapons,
chips,
protocols,
brands,
summary,
attributes: summaryCatalog.attributes,
talents: summaryCatalog.talents,
};
}
+117
View File
@@ -0,0 +1,117 @@
import type { Database } from '@tursodatabase/database';
import {
SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES,
SKILL_CHIP_QUALITIES,
SKILL_CHIP_SECONDARY_QUALITIES,
type SkillChipProfile,
} from '$lib/skill-chips/profiles';
export async function getSkillChipPrimaryAttributeIds(database: Database) {
const rows = await (
await database.prepare(
`SELECT id,name FROM stat_attributes
WHERE active=1 AND name IN (${SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES.map(() => '?').join(',')})
ORDER BY name COLLATE NOCASE`,
)
).all(...SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES);
if (rows.length !== SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES.length)
throw new Error(
'All four fixed Skill Chip primary Attributes must be active.',
);
return rows.map((row) => String(row.id));
}
async function getProfiles(
database: Database,
optionTable: string,
valueTable: string,
) {
const [options, values] = await Promise.all([
(
await database.prepare(
`SELECT stat_attribute_id FROM ${optionTable} ORDER BY stat_attribute_id`,
)
).all(),
(
await database.prepare(
`SELECT * FROM ${valueTable} ORDER BY stat_attribute_id,quality`,
)
).all(),
]);
if (!options.length) return [];
return options.map((option) => ({
statId: String(option.stat_attribute_id),
values: Object.fromEntries(
values
.filter((value) => value.stat_attribute_id === option.stat_attribute_id)
.map((value) => [
String(value.quality),
{ min: Number(value.min_value), max: Number(value.max_value) },
]),
),
}));
}
export const getSkillChipPrimaryProfiles = (database: Database) =>
getProfiles(
database,
'skill_chip_primary_options',
'skill_chip_primary_quality_values',
);
export const getSkillChipSecondaryPool = (database: Database) =>
getProfiles(
database,
'skill_chip_secondary_options',
'skill_chip_secondary_quality_values',
);
async function replaceProfiles(
database: Database,
optionTable: string,
valueTable: string,
profiles: SkillChipProfile[],
qualities: readonly string[],
) {
await database.exec(`DELETE FROM ${valueTable}`);
await database.exec(`DELETE FROM ${optionTable}`);
for (const profile of profiles) {
await (
await database.prepare(
`INSERT INTO ${optionTable}(stat_attribute_id) VALUES(?)`,
)
).run(profile.statId);
for (const quality of qualities) {
const range = profile.values[quality];
await (
await database.prepare(
`INSERT INTO ${valueTable}(stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?)`,
)
).run(profile.statId, quality, range.min, range.max);
}
}
}
export const replaceSkillChipPrimaryProfiles = (
database: Database,
profiles: SkillChipProfile[],
) =>
replaceProfiles(
database,
'skill_chip_primary_options',
'skill_chip_primary_quality_values',
profiles,
SKILL_CHIP_QUALITIES,
);
export const replaceSkillChipSecondaryPool = (
database: Database,
profiles: SkillChipProfile[],
) =>
replaceProfiles(
database,
'skill_chip_secondary_options',
'skill_chip_secondary_quality_values',
profiles,
SKILL_CHIP_SECONDARY_QUALITIES,
);
+39 -24
View File
@@ -17,37 +17,51 @@ export async function refreshSpecializationFocusReady(
abilityCount: 0,
chipReadyCount: 0,
};
const [config, abilityCountRow, chipReadyRow, nodes, rankProfiles] =
await Promise.all([
(
await database.prepare(
"SELECT xp_points,shd_cache_points FROM specialization_progression_config WHERE id='default'",
)
).get(),
(
await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
const [
config,
abilityCountRow,
profileRow,
chipReadyRow,
nodes,
rankProfiles,
] = await Promise.all([
(
await database.prepare(
"SELECT xp_points,shd_cache_points FROM specialization_progression_config WHERE id='default'",
)
).get(),
(
await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
JOIN specialization_abilities ability ON ability.id=link.ability_id
WHERE link.focus_id=? AND ability.active=1`)
).get(focusId),
(
await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
).get(focusId),
(
await database.prepare(`SELECT
(SELECT COUNT(*) FROM skill_chip_primary_options)=4
AND (SELECT COUNT(*) FROM skill_chip_primary_quality_values)=16
AND (SELECT COUNT(*) FROM skill_chip_secondary_options)>=2
AND (SELECT COUNT(*) FROM skill_chip_secondary_quality_values)=
(SELECT COUNT(*)*3 FROM skill_chip_secondary_options) AS ready`)
).get(),
(
await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
WHERE link.focus_id=? AND (
SELECT COUNT(*) FROM skill_chip_sets chips
WHERE chips.specialization_ability_id=ability.id AND chips.active=1
)=3`)
).get(focusId),
(
await database.prepare(
'SELECT row_number,max_rank FROM specialization_tree_nodes WHERE focus_id=? AND active=1 ORDER BY row_number,column_number',
)
).all(focusId),
(
await database.prepare(
'SELECT rank,point_cost FROM specialization_rank_cost_profiles ORDER BY rank',
)
).all(),
]);
).get(focusId),
(
await database.prepare(
'SELECT row_number,max_rank FROM specialization_tree_nodes WHERE focus_id=? AND active=1 ORDER BY row_number,column_number',
)
).all(focusId),
(
await database.prepare(
'SELECT rank,point_cost FROM specialization_rank_cost_profiles ORDER BY rank',
)
).all(),
]);
const pointForRank = new Map(
rankProfiles.map((profile) => [
Number(profile.rank),
@@ -69,6 +83,7 @@ export async function refreshSpecializationFocusReady(
Number(focus.active) === 1 &&
abilityCount === 3 &&
chipReadyCount === 3 &&
Number(profileRow?.ready) === 1 &&
nodes.length > 0 &&
contiguousRows &&
pointTotal === availablePoints;
+179
View File
@@ -0,0 +1,179 @@
import { describe, expect, it } from 'vitest';
import {
normalizeSkillChipProfiles,
SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES,
SKILL_CHIP_QUALITIES,
SKILL_CHIP_SECONDARY_CAPACITY,
SKILL_CHIP_SECONDARY_QUALITIES,
} from './profiles';
const primaryIds = [
'skill-duration',
'skill-radius',
'skill-cooldown-recovery',
'skill-intensity',
] as const;
const ranges = (qualities: readonly string[], offset = 0) =>
Object.fromEntries(
qualities.map((quality, index) => [
quality,
{ min: String(offset + index + 1), max: offset + index + 2 },
]),
);
const profile = (statId: string, qualities: readonly string[], offset = 0) => ({
statId,
values: ranges(qualities, offset),
});
describe('Skill Chip shared profiles', () => {
it('defines the exact four fixed primary Attributes and all four qualities', () => {
expect(SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES).toEqual([
'Skill Duration',
'Skill Radius',
'Skill Cooldown Recovery',
'Skill Intensity',
]);
expect(SKILL_CHIP_QUALITIES).toEqual([
'Standard',
'Specialized',
'Superior',
'High-end',
]);
});
it('defines the 0/1/2/2 secondary capacity mapping', () => {
expect(SKILL_CHIP_SECONDARY_CAPACITY).toEqual({
Standard: 0,
Specialized: 1,
Superior: 2,
'High-end': 2,
});
});
it('normalizes exactly the four fixed primary profiles across every quality', () => {
const raw = primaryIds.map((statId, index) =>
profile(` ${statId} `, SKILL_CHIP_QUALITIES, index * 10),
);
const result = normalizeSkillChipProfiles(raw, SKILL_CHIP_QUALITIES, {
expectedStatIds: primaryIds,
minimumOptions: 4,
});
expect(result.error).toBeNull();
expect(result.profiles).toHaveLength(4);
expect(result.profiles.map(({ statId }) => statId)).toEqual(primaryIds);
expect(result.profiles[0]).toEqual({
statId: 'skill-duration',
values: {
Standard: { min: 1, max: 2 },
Specialized: { min: 2, max: 3 },
Superior: { min: 3, max: 4 },
'High-end': { min: 4, max: 5 },
},
});
});
it('rejects a primary profile that does not contain the exact fixed set', () => {
const raw = primaryIds.map((statId) =>
profile(statId, SKILL_CHIP_QUALITIES),
);
raw[3] = profile('weapon-damage', SKILL_CHIP_QUALITIES);
expect(
normalizeSkillChipProfiles(raw, SKILL_CHIP_QUALITIES, {
expectedStatIds: primaryIds,
minimumOptions: 4,
}),
).toEqual({
profiles: [],
error:
'The primary profile must contain the four fixed Skill Attributes.',
});
});
it('normalizes secondary profiles across only the three supported qualities', () => {
expect(SKILL_CHIP_SECONDARY_QUALITIES).toEqual([
'Specialized',
'Superior',
'High-end',
]);
const result = normalizeSkillChipProfiles(
[
profile(' critical-hit-chance ', SKILL_CHIP_SECONDARY_QUALITIES),
profile('critical-hit-damage', SKILL_CHIP_SECONDARY_QUALITIES, 10),
],
SKILL_CHIP_SECONDARY_QUALITIES,
{ minimumOptions: 2 },
);
expect(result.error).toBeNull();
expect(result.profiles[0]).toEqual({
statId: 'critical-hit-chance',
values: {
Specialized: { min: 1, max: 2 },
Superior: { min: 2, max: 3 },
'High-end': { min: 3, max: 4 },
},
});
expect(result.profiles[0].values).not.toHaveProperty('Standard');
});
it('rejects duplicate Attributes after trimming IDs', () => {
const result = normalizeSkillChipProfiles(
[
profile('critical-hit-chance', SKILL_CHIP_SECONDARY_QUALITIES),
profile(' critical-hit-chance ', SKILL_CHIP_SECONDARY_QUALITIES),
],
SKILL_CHIP_SECONDARY_QUALITIES,
{ minimumOptions: 2 },
);
expect(result).toEqual({
profiles: [],
error: 'Choose a unique Attribute for every shared option.',
});
});
it.each([
[
'missing ranges',
{
Specialized: { min: 1, max: 2 },
Superior: { min: 2, max: 3 },
},
'High-end requires a minimum and maximum.',
],
[
'reversed ranges',
{
Specialized: { min: 2, max: 1 },
Superior: { min: 2, max: 3 },
'High-end': { min: 3, max: 4 },
},
'Specialized requires a valid non-negative range.',
],
[
'negative ranges',
{
Specialized: { min: -1, max: 2 },
Superior: { min: 2, max: 3 },
'High-end': { min: 3, max: 4 },
},
'Specialized requires a valid non-negative range.',
],
])('rejects %s', (_label, values, error) => {
const result = normalizeSkillChipProfiles(
[
{ statId: 'critical-hit-chance', values },
profile('critical-hit-damage', SKILL_CHIP_SECONDARY_QUALITIES),
],
SKILL_CHIP_SECONDARY_QUALITIES,
{ minimumOptions: 2 },
);
expect(result).toEqual({ profiles: [], error });
});
});
+127
View File
@@ -0,0 +1,127 @@
export const SKILL_CHIP_QUALITIES = [
'Standard',
'Specialized',
'Superior',
'High-end',
] as const;
export const SKILL_CHIP_SECONDARY_QUALITIES = [
'Specialized',
'Superior',
'High-end',
] as const;
export const SKILL_CHIP_SECONDARY_CAPACITY = {
Standard: 0,
Specialized: 1,
Superior: 2,
'High-end': 2,
} as const satisfies Record<(typeof SKILL_CHIP_QUALITIES)[number], number>;
export const SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES = [
'Skill Duration',
'Skill Radius',
'Skill Cooldown Recovery',
'Skill Intensity',
] as const;
export type SkillChipRange = { min: number; max: number };
export type SkillChipProfile = {
statId: string;
values: Record<string, SkillChipRange>;
};
export function hydrateSkillChipProfileRows(
options: Array<Record<string, unknown>>,
qualityValues: Array<Record<string, unknown>>,
) {
return options.map((option) => ({
statId: String(option.stat_attribute_id),
ranges: Object.fromEntries(
qualityValues
.filter((range) => range.stat_attribute_id === option.stat_attribute_id)
.map((range) => [
String(range.quality),
{ min: Number(range.min_value), max: Number(range.max_value) },
]),
),
}));
}
const finite = (value: unknown) => {
if (value === '' || value === null || value === undefined) return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
};
export function normalizeSkillChipProfiles(
raw: unknown,
qualities: readonly string[],
options: {
expectedStatIds?: readonly string[];
minimumOptions?: number;
} = {},
): { profiles: SkillChipProfile[]; error: string | null } {
if (!Array.isArray(raw))
return { profiles: [], error: 'The shared profile payload is invalid.' };
if (raw.length < (options.minimumOptions ?? 1) || raw.length > 50)
return {
profiles: [],
error: `Configure ${options.minimumOptions ?? 1}–50 shared Attribute options.`,
};
const profiles: SkillChipProfile[] = [];
const seen = new Set<string>();
for (const entry of raw) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
return { profiles: [], error: 'A shared Attribute option is invalid.' };
const candidate = entry as Record<string, unknown>;
const statId = String(candidate.statId ?? '').trim();
if (!statId || seen.has(statId))
return {
profiles: [],
error: 'Choose a unique Attribute for every shared option.',
};
seen.add(statId);
const rawValues = candidate.values;
if (!rawValues || typeof rawValues !== 'object' || Array.isArray(rawValues))
return {
profiles: [],
error: 'Every shared Attribute needs quality ranges.',
};
const values: Record<string, SkillChipRange> = {};
for (const quality of qualities) {
const rawRange = (rawValues as Record<string, unknown>)[quality];
if (!rawRange || typeof rawRange !== 'object' || Array.isArray(rawRange))
return {
profiles: [],
error: `${quality} requires a minimum and maximum.`,
};
const range = rawRange as Record<string, unknown>;
const min = finite(range.min);
const max = finite(range.max);
if (min === null || max === null || min < 0 || max < min)
return {
profiles: [],
error: `${quality} requires a valid non-negative range.`,
};
values[quality] = { min, max };
}
profiles.push({ statId, values });
}
if (options.expectedStatIds) {
const expected = [...options.expectedStatIds].sort();
const received = profiles.map((profile) => profile.statId).sort();
if (
expected.length !== received.length ||
expected.some((statId, index) => statId !== received[index])
)
return {
profiles: [],
error:
'The primary profile must contain the four fixed Skill Attributes.',
};
}
return { profiles, error: null };
}
+49
View File
@@ -1,10 +1,15 @@
import { describe, expect, it } from 'vitest';
import {
WEAPON_MOD_SLOT_DEFINITIONS,
WEAPON_MOD_SLOT_TYPES,
excludedTierZeroWeaponModSlot,
expandSharedWeaponModSlots,
normalizeWeaponModSlotPayload,
publicWeaponModSlots,
sharedWeaponModSlots,
validateWeaponModSlots,
weaponModSlotDefinitionById,
weaponModSlotDefinitionByName,
weaponModSlotsComplete,
type WeaponModSlotConfig,
} from './mod-slots';
@@ -15,6 +20,23 @@ const configuration = (tier: number, slots: string[]): WeaponModSlotConfig => ({
});
describe('weapon mod slot configuration', () => {
it('assigns one stable UUID to every canonical slot type', () => {
expect(WEAPON_MOD_SLOT_DEFINITIONS).toHaveLength(8);
expect(new Set(WEAPON_MOD_SLOT_DEFINITIONS.map(({ id }) => id)).size).toBe(
8,
);
expect(WEAPON_MOD_SLOT_DEFINITIONS.map(({ name }) => name)).toEqual(
WEAPON_MOD_SLOT_TYPES,
);
for (const definition of WEAPON_MOD_SLOT_DEFINITIONS) {
expect(definition.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
expect(weaponModSlotDefinitionById(definition.id)).toBe(definition);
expect(weaponModSlotDefinitionByName(definition.name)).toBe(definition);
}
});
it('accepts missing and partial slot data for later configuration', () => {
const slots = [
configuration(0, []),
@@ -99,4 +121,31 @@ describe('weapon mod slot configuration', () => {
]),
).toEqual([configuration(1, ['Optics', 'Muzzle', 'Barrel'])]);
});
it('marks the slot gained by a native T0 weapon at T1', () => {
const slots = expandSharedWeaponModSlots(
['Magazine', 'Optics', 'Barrel', 'Muzzle', 'Underbarrel'],
'Barrel',
);
expect(publicWeaponModSlots(0, slots)).toEqual([
{ slot: 'Optics', unlockedAtTierOne: false },
{ slot: 'Muzzle', unlockedAtTierOne: false },
{ slot: 'Underbarrel', unlockedAtTierOne: false },
{ slot: 'Magazine', unlockedAtTierOne: false },
{ slot: 'Barrel', unlockedAtTierOne: true },
]);
});
it('shows native T1 slots without an upgrade-only state', () => {
expect(
publicWeaponModSlots(1, [
configuration(1, ['Magazine', 'Optics', 'Muzzle']),
]),
).toEqual([
{ slot: 'Optics', unlockedAtTierOne: false },
{ slot: 'Muzzle', unlockedAtTierOne: false },
{ slot: 'Magazine', unlockedAtTierOne: false },
]);
});
});
+59 -9
View File
@@ -1,19 +1,42 @@
export const WEAPON_MOD_SLOT_TYPES = [
'Optics',
'Muzzle',
'Underbarrel',
'Magazine',
'Barrel',
'Upper Receiver',
'Lower Receiver',
'Trigger',
export const WEAPON_MOD_SLOT_DEFINITIONS = [
{ id: '28ffc3b1-5d98-4273-a9af-67dbff3e5c76', name: 'Optics' },
{ id: 'c4598f51-2cb0-4a20-a3a7-6d8983a26598', name: 'Muzzle' },
{ id: '85ca3493-1871-4d22-950a-ed718fd547e0', name: 'Underbarrel' },
{ id: '4d809a35-8285-4bff-be98-a0effe1d257e', name: 'Magazine' },
{ id: '0e157cfe-975c-441d-8f49-72983161e7b1', name: 'Barrel' },
{ id: '551fd7c8-3e5b-4c89-8437-9b901e89e8aa', name: 'Upper Receiver' },
{ id: '72573367-6e91-4312-9cc3-710ede4f078e', name: 'Lower Receiver' },
{ id: '8dda5edb-6079-4a23-a556-cd9be4521c70', name: 'Trigger' },
] as const;
export type WeaponModSlotType =
(typeof WEAPON_MOD_SLOT_DEFINITIONS)[number]['name'];
export const WEAPON_MOD_SLOT_TYPES = WEAPON_MOD_SLOT_DEFINITIONS.map(
(definition) => definition.name,
) as WeaponModSlotType[];
export function weaponModSlotDefinitionById(id: string) {
return WEAPON_MOD_SLOT_DEFINITIONS.find((definition) => definition.id === id);
}
export function weaponModSlotDefinitionByName(name: string) {
return WEAPON_MOD_SLOT_DEFINITIONS.find(
(definition) => definition.name === name,
);
}
export type WeaponModSlotConfig = {
tier: number;
slots: string[];
};
export type PublicWeaponModSlot = {
slot: string;
unlockedAtTierOne: boolean;
iconUrl?: string;
};
export function normalizeWeaponModSlotPayload(
payload: unknown,
): WeaponModSlotConfig[] {
@@ -52,6 +75,33 @@ export function sharedWeaponModSlots(
];
}
export function publicWeaponModSlots(
nativeTier: number,
configurations: WeaponModSlotConfig[],
): PublicWeaponModSlot[] {
const nativeSlots =
configurations.find((row) => Number(row.tier) === nativeTier)?.slots ?? [];
const displayedSlots =
nativeTier === 0
? sharedWeaponModSlots(configurations).length
? sharedWeaponModSlots(configurations)
: nativeSlots
: nativeSlots;
const ordered = [...new Set(displayedSlots)].sort(
(left, right) =>
WEAPON_MOD_SLOT_TYPES.indexOf(
left as (typeof WEAPON_MOD_SLOT_TYPES)[number],
) -
WEAPON_MOD_SLOT_TYPES.indexOf(
right as (typeof WEAPON_MOD_SLOT_TYPES)[number],
),
);
return ordered.map((slot) => ({
slot,
unlockedAtTierOne: nativeTier === 0 && !nativeSlots.includes(slot),
}));
}
export function excludedTierZeroWeaponModSlot(
sharedSlots: string[],
configurations: WeaponModSlotConfig[],
+55
View File
@@ -92,6 +92,10 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => {
WHERE users.id=?`)
).get(agentId);
const isSelf = viewer?.id === agentId;
const canManageNews =
isSelf &&
(viewerPermissions.includes('*') ||
viewerPermissions.includes('news.manage'));
if (unavailable(agent) || (!Boolean(agent?.is_public) && !isSelf))
error(404, 'Agent profile not found');
if (params.id !== String(agent.public_slug)) {
@@ -129,6 +133,9 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => {
profileCommentCount,
buildCount,
guideCount,
ownedBuildRows,
ownedGuideRows,
ownedNewsRows,
] = await Promise.all([
(
await database.prepare(`SELECT roles.name FROM user_roles
@@ -216,6 +223,28 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => {
await database.prepare(`SELECT COUNT(*) AS count FROM articles
WHERE kind='guide' AND owner_id=? AND visibility='public' AND deleted_at IS NULL`)
).get(agentId),
isSelf
? (
await database.prepare(`SELECT id,slug,title,visibility,updated_at
FROM builds WHERE owner_id=? ORDER BY updated_at DESC`)
).all(agentId)
: [],
isSelf
? (
await database.prepare(`SELECT articles.id,articles.slug,draft.title,articles.visibility,articles.updated_at
FROM articles JOIN article_drafts draft ON draft.article_id=articles.id
WHERE articles.kind='guide' AND articles.owner_id=? AND articles.deleted_at IS NULL
ORDER BY articles.updated_at DESC`)
).all(agentId)
: [],
canManageNews
? (
await database.prepare(`SELECT articles.id,articles.slug,draft.title,articles.visibility,articles.updated_at
FROM articles JOIN article_drafts draft ON draft.article_id=articles.id
WHERE articles.kind='news' AND articles.owner_id=? AND articles.deleted_at IS NULL
ORDER BY articles.updated_at DESC`)
).all(agentId)
: [],
]);
const loadedCommentIds = new Set(
@@ -401,6 +430,32 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => {
title: String(guide.title),
updatedAt: String(guide.updated_at),
})),
ownedContent: isSelf
? {
builds: ownedBuildRows.map((build) => ({
id: String(build.id),
slug: String(build.slug),
title: String(build.title),
visibility: String(build.visibility),
updatedAt: String(build.updated_at),
})),
guides: ownedGuideRows.map((guide) => ({
id: String(guide.id),
slug: String(guide.slug),
title: String(guide.title),
visibility: String(guide.visibility),
updatedAt: String(guide.updated_at),
})),
news: ownedNewsRows.map((news) => ({
id: String(news.id),
slug: String(news.slug),
title: String(news.title),
visibility: String(news.visibility),
updatedAt: String(news.updated_at),
})),
canManageNews,
}
: null,
authoredComments: authoredCommentRows.map((comment) => {
const targetType = String(comment.target_type);
const targetId = String(comment.target_id);
+50 -1
View File
@@ -32,6 +32,7 @@
title: string;
updatedAt: string;
};
type OwnedContent = PublicContent & { visibility: string };
type AuthoredComment = {
id: string;
createdAt: string;
@@ -60,6 +61,12 @@
counts: { builds: number; guides: number; authoredComments: number; profileComments: number };
builds: PublicContent[];
guides: PublicContent[];
ownedContent: {
builds: OwnedContent[];
guides: OwnedContent[];
news: OwnedContent[];
canManageNews: boolean;
} | null;
authoredComments: AuthoredComment[];
profileComments: ProfileComment[];
viewer: { id: string | null; canComment: boolean; canReportProfile: boolean; isSelf: boolean };
@@ -86,6 +93,7 @@
? 'DATE UNKNOWN'
: date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }).toUpperCase();
};
const visibilityLabel = (visibility: string) => visibility.toUpperCase().replace('_', ' ');
$effect(() => {
const token = String(form?.profileCommentResultToken ?? form?.commentResultToken ?? '');
@@ -230,7 +238,7 @@
<header><div><p class="eyebrow">LOADOUT ARCHIVE</p><h2>PUBLIC BUILDS</h2></div><span>{data.counts.builds} TOTAL</span></header>
<div class="public-content-grid">
{#each data.builds as build}
<a class="public-content-card" href={`/builds/${encodeURIComponent(build.slug)}`}><span>LOADOUT</span><h3>{build.title}</h3><p>Open this loadout to inspect its equipment, Attributes, and calculated performance.</p><footer><small>UPDATED {dateLabel(build.updatedAt)}</small><b>VIEW BUILD →</b></footer></a>
<a class="public-content-card" href={`/builds/${encodeURIComponent(build.slug)}`}><span>LOADOUT</span><h3>{build.title}</h3><p>Open this loadout to inspect its equipment, known Attributes, and active set bonuses.</p><footer><small>UPDATED {dateLabel(build.updatedAt)}</small><b>VIEW BUILD →</b></footer></a>
{:else}<div class="public-empty"><span>◇</span><strong>NO PUBLIC BUILDS</strong><p>This Agent has not shared a public loadout.</p></div>{/each}
</div>
</section>
@@ -242,6 +250,44 @@
</div>
</section>
</div>
{#if data.viewer.isSelf && data.ownedContent}
<section class="profile-section content-control">
<header>
<div><p class="eyebrow">OWNER WORKSPACE</p><h2>CONTENT CONTROL</h2></div>
<span>PRIVATE TO YOU</span>
</header>
<p class="content-control-intro">Open any authored item to edit it or change its visibility. Private and unlisted items appear only here.</p>
<div class="content-control-grid">
<section>
<header><span>LOADOUTS</span><a href="/builder">NEW BUILD +</a></header>
<div class="content-control-list">
{#each data.ownedContent.builds as build}
<a href={`/builder?build=${encodeURIComponent(build.slug)}`}><span><b>{build.title}</b><small>UPDATED {dateLabel(build.updatedAt)}</small></span><em>{visibilityLabel(build.visibility)}</em><strong>EDIT →</strong></a>
{:else}<p class="content-control-empty">No builds yet. Create one in the Loadout Lab.</p>{/each}
</div>
</section>
<section>
<header><span>GUIDES</span><a href="/guides/new">NEW GUIDE +</a></header>
<div class="content-control-list">
{#each data.ownedContent.guides as guide}
<a href={`/guides/${encodeURIComponent(guide.slug)}/edit`}><span><b>{guide.title}</b><small>UPDATED {dateLabel(guide.updatedAt)}</small></span><em>{visibilityLabel(guide.visibility)}</em><strong>EDIT →</strong></a>
{:else}<p class="content-control-empty">No Guides yet. Start a field guide when you are ready.</p>{/each}
</div>
</section>
{#if data.ownedContent.canManageNews}
<section>
<header><span>NEWS POSTS</span><a href="/news/new">NEW ARTICLE +</a></header>
<div class="content-control-list">
{#each data.ownedContent.news as news}
<a href={`/news/${encodeURIComponent(news.slug)}/edit`}><span><b>{news.title}</b><small>UPDATED {dateLabel(news.updatedAt)}</small></span><em>{visibilityLabel(news.visibility)}</em><strong>EDIT →</strong></a>
{:else}<p class="content-control-empty">No news posts authored by this Agent.</p>{/each}
</div>
</section>
{/if}
</div>
</section>
{/if}
</div>
<section class="profile-comms" id="profile-comments">
@@ -392,6 +438,7 @@
.profile-section{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}.profile-section>header{min-height:54px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 12px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.profile-section>header>div{display:grid;gap:2px}.profile-section>header :is(h1,h2,p){margin:0}.profile-section>header h1,.profile-section>header h2{font-size:var(--shd-type-body);letter-spacing:.4px}.profile-section>header>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
.bio-content{min-height:110px;padding:18px}:global(form.profile-bio-form){display:grid;gap:10px;padding:16px}.profile-bio-actions{display:flex;justify-content:flex-end;gap:7px}.profile-bio-actions button{min-height:40px}.profile-owner-notice{display:grid;gap:4px;margin:12px 16px 0;padding:10px;border:1px solid #745b2b;background:#1c170e;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.profile-owner-notice strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.7px}.profile-owner-notice.error{border-color:#70423b;background:#211414}.profile-owner-notice.error strong{color:var(--shd-danger)}.owner-profile-result{margin:10px 16px 0}.public-content-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;padding:7px}
.public-content-card{min-width:0;display:grid;grid-template-rows:auto auto minmax(48px,1fr) auto;gap:6px;padding:12px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-orange);background:#0d1011;color:var(--shd-text)}.public-content-card>span,.public-content-card footer{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.public-content-card>span{color:var(--shd-orange)}.public-content-card h3,.public-content-card p{margin:0}.public-content-card h3{font-size:1.05rem}.public-content-card p{color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting);line-height:1.45}.public-content-card footer{display:flex;justify-content:space-between;gap:8px;padding-top:8px;border-top:1px solid var(--shd-border-soft)}.public-content-card small{color:var(--shd-text-dim)}.public-content-card b{color:var(--shd-orange)}.public-content-card:hover,.public-content-card:focus-visible{border-color:var(--shd-orange);background:var(--shd-surface-active)}
.content-control{margin-top:10px}.content-control-intro{margin:0;padding:10px 12px;border-bottom:1px solid var(--shd-border);color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting)}.content-control-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px;padding:7px}.content-control-grid>section{min-width:0;border:1px solid var(--shd-border);background:#0d1011}.content-control-grid>section>header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.content-control-grid>section>header :is(span,a){font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.content-control-grid>section>header span{color:var(--shd-text-muted)}.content-control-grid>section>header a,.content-control-list strong{color:var(--shd-orange)}.content-control-list{display:grid}.content-control-list>a{min-width:0;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px;padding:10px;border-top:1px solid var(--shd-border);color:var(--shd-text)}.content-control-list>a:first-child{border-top:0}.content-control-list>a>span{min-width:0;display:grid;gap:3px}.content-control-list b{overflow:hidden;font-size:var(--shd-type-supporting);text-overflow:ellipsis;white-space:nowrap}.content-control-list small,.content-control-list em,.content-control-list strong{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.65px}.content-control-list small{color:var(--shd-text-dim)}.content-control-list em{justify-self:start;padding:2px 5px;border:1px solid var(--shd-border-strong);color:var(--shd-text-muted);font-style:normal}.content-control-list strong{grid-column:2;grid-row:1 / span 2;align-self:center}.content-control-list>a:hover,.content-control-list>a:focus-visible{background:var(--shd-surface-active);outline:1px solid var(--shd-orange);outline-offset:-1px}.content-control-empty{min-height:92px;display:grid;place-content:center;margin:0;padding:12px;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting);text-align:center}
.public-empty{grid-column:1/-1;min-height:150px;display:grid;place-content:center;gap:6px;padding:20px;text-align:center}.public-empty span{color:var(--shd-orange);font-size:28px}.public-empty strong{font-size:var(--shd-type-telemetry);letter-spacing:1px}.public-empty p{margin:0;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting)}.public-empty.compact{min-height:110px}
.authored-comment-list{display:grid;gap:7px;padding:7px}.authored-comment-list>article{display:grid;gap:10px;padding:11px;border:1px solid var(--shd-border);background:#0d1011}.authored-comment-list article>header{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:10px;padding:0;border:0;background:transparent}.authored-comment-list article>header>div{min-width:0;display:grid;align-content:center;gap:2px}.authored-comment-list article header span,.authored-comment-list article header small,.authored-comment-list article>a{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.authored-comment-list article header span,.authored-comment-list article>a{color:var(--shd-orange)}.authored-comment-list article header small{align-self:center;color:var(--shd-text-muted);text-align:right;white-space:nowrap}.authored-comment-list article>a{justify-self:end}
.profile-comms{margin-top:16px;padding:0;background:transparent}.profile-comms-heading{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:flex;align-items:end;justify-content:space-between;gap:16px;padding:0 0 12px;border:0;border-bottom:1px solid var(--shd-border);background:transparent}.profile-comms-heading>div{display:grid;gap:3px}.profile-comms-heading :is(h2,p){margin:0}.profile-comms-heading h2{font-size:1.25rem}.profile-comms-heading>span{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.profile-comment-start{width:min(920px,100%);padding-top:16px}.profile-comment-trigger{width:100%;min-height:48px;padding:0 14px;border:1px solid var(--shd-border-strong);background:transparent;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-body);text-align:left;cursor:pointer}.profile-comment-trigger:hover,.profile-comment-trigger:focus-visible,.profile-comment-trigger[aria-expanded=true]{border-color:var(--shd-orange);color:var(--shd-text);background:var(--shd-surface-active)}:global(form.profile-comment-form){width:min(920px,100%);display:grid;gap:9px;padding:14px 0 0;border:0;background:transparent}.profile-comment-form-actions{display:flex;justify-content:flex-end;gap:7px}.profile-comment-form-actions button{min-height:38px}.profile-comment-list{width:min(980px,100%);display:grid;margin-top:26px;overflow:visible}.profile-comment-list>article{--thread-depth:0;position:relative;display:grid;gap:8px;margin:18px 0 0 calc(var(--thread-depth) * 22px);padding:0;scroll-margin-top:96px;background:transparent}.profile-comment-list>article:target{outline:1px solid var(--shd-orange);outline-offset:8px}.profile-comment-list>article.reply{padding-left:14px;border-left:1px solid var(--shd-border-strong)}.profile-comment-list article>header{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:flex;align-items:center;justify-content:flex-start;gap:12px;padding:0;border:0;background:transparent}.profile-comment-list article>header :global(.agent-identity){flex:0 1 auto}.profile-comment-list article :global(.markdown-content){max-width:75ch;margin-left:47px}.comment-actions{display:flex;align-items:center;gap:14px;min-height:32px;margin-left:47px}.comment-reply{min-height:32px;padding:0;border:0;background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px;cursor:pointer}.comment-reply:hover,.comment-reply:focus-visible,.comment-reply[aria-expanded=true]{color:var(--shd-orange);text-decoration:underline;text-underline-offset:3px}.reply-composer{margin:6px 0 0 47px}.reply-composer :global(form.profile-comment-form){padding-top:0}.removed-message{display:grid;gap:3px;padding:4px 0}.removed-message strong{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.removed-message span{color:var(--shd-text-dim);font-size:var(--shd-type-supporting);font-style:italic}.comment-delete-confirm{width:min(520px,calc(100% - 47px));display:grid;gap:8px;margin-left:47px;padding:10px;border:1px solid var(--shd-danger);background:#1b1111}.comment-delete-confirm p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-delete-confirm>div{display:flex;justify-content:flex-end;gap:7px}.comment-delete-confirm button{min-height:38px;padding:0 10px}.comment-access-note{width:min(920px,100%);display:grid;gap:7px;padding:16px 0 0}.comment-access-note strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.comment-access-note p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-access-note a{justify-self:start}.profile-comms .public-empty{min-height:100px;place-content:start;padding:24px 0;text-align:left}.profile-comms .public-empty span{display:none}
@@ -399,6 +446,8 @@
.profile-comment-list article :global(.markdown-content),.comment-actions{margin-left:53px}.reply-composer{margin-left:53px}.comment-delete-confirm{width:min(520px,calc(100% - 53px));margin-left:53px}
.pinned-comment-label{margin-left:auto;color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.7px}
@media(max-width:1000px){.agent-profile-grid{grid-template-columns:1fr}.agent-main-column{grid-column:1}.agent-activity{position:static;grid-column:1;grid-template-columns:repeat(2,minmax(0,1fr))}.agent-activity .profile-section:last-child{grid-column:1/-1}.agent-telemetry{grid-template-columns:repeat(2,minmax(0,1fr))}.agent-telemetry>div:nth-child(3){border-left:0}.agent-telemetry>div:nth-child(n+3){border-top:1px solid var(--shd-border)}}
@media(max-width:1000px){.content-control-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:650px){.content-control-grid{grid-template-columns:1fr}}
@media(max-width:650px){.agent-command{grid-template-columns:1fr;align-items:stretch}.agent-command-actions{width:100%;display:grid;grid-template-columns:minmax(0,1fr) auto}.agent-command-actions :global(.profile-visibility-form),.agent-command-actions .visibility-toggle{width:100%}.agent-command-actions .visibility-toggle{min-height:44px;justify-content:center}.agent-command-actions :global(.content-report){grid-column:1/-1}.agent-command-actions :global(.content-report summary){width:100%;justify-content:center}.mobile-account-jump{grid-column:1/-1;min-height:44px;display:flex;align-items:center;justify-content:center;border:1px solid var(--shd-border-strong);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.mobile-account-jump:hover,.mobile-account-jump:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange);background:var(--shd-surface-active)}.agent-main-column{display:contents}.agent-primary{grid-column:1;grid-row:1}.agent-activity{grid-column:1;grid-row:2;grid-template-columns:1fr}.profile-comms{grid-column:1;grid-row:3;margin-top:16px}.agent-activity .profile-section:last-child{grid-column:auto}.agent-published-grid{grid-template-columns:1fr}.public-content-grid{grid-template-columns:1fr}.profile-section>header,.profile-comms-heading{align-items:flex-start;flex-direction:column}.agent-bio>header{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start}.agent-bio .profile-header-actions{width:auto}.bio-edit-trigger{min-height:44px;padding:0 5px;border-color:transparent}.bio-edit-trigger:hover,.bio-edit-trigger:focus-visible,.bio-edit-trigger[aria-expanded=true]{border-color:var(--shd-border-strong)}.profile-bio-actions{display:grid;grid-template-columns:1fr 1fr}.profile-bio-actions button{min-height:44px}.profile-comment-trigger,.profile-comment-form-actions button,.comment-reply,.comment-delete-confirm button{min-height:44px}.profile-comment-form-actions{display:grid;grid-template-columns:1fr 1fr}.profile-comment-list>article{margin-left:calc(var(--thread-depth) * 12px)}.comment-delete-confirm>div{display:grid;grid-template-columns:1fr 1fr}}
@media(prefers-reduced-motion:reduce){.public-content-card,.profile-comment-trigger,.comment-reply{transition:none}}
</style>
@@ -60,7 +60,10 @@ export const POST: RequestHandler = async ({ cookies, request, url }) => {
}
try {
const authoringRequest = parseAiCalculationAuthoringRequest(payload);
const release = reserveAiMechanicGeneration(operator.id);
const release = reserveAiMechanicGeneration(
operator.id,
operator.role === 'admin',
);
let response;
try {
response = await generateAiCalculationDraft(
@@ -58,7 +58,10 @@ export const POST: RequestHandler = async ({ cookies, request, url }) => {
}
try {
const authoringRequest = parseAiMechanicAuthoringRequest(payload);
const release = reserveAiMechanicGeneration(operator.id);
const release = reserveAiMechanicGeneration(
operator.id,
operator.role === 'admin',
);
let response;
try {
response = await generateAiMechanicDraft(authoringRequest, operator.id);
+257 -124
View File
@@ -3,9 +3,11 @@ 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 { parseLoadoutDraft } from '$lib/builder/loadout';
import { parseLoadoutDraft, type LoadoutDraft } from '$lib/builder/loadout';
import { publicOrigin } from '$lib/server/public-origin';
import type { Actions, PageServerLoad } from './$types';
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
import { hydrateSkillChipProfileRows } from '$lib/skill-chips/profiles';
const validVisibility = new Set(['private', 'unlisted', 'public']);
const slugify = (value: string) =>
@@ -27,6 +29,196 @@ function parseLoadout(raw: FormDataEntryValue | null) {
}
}
async function validateSkillChipSelections(
database: Awaited<ReturnType<typeof getDatabase>>,
loadout: LoadoutDraft,
) {
if (!loadout.chips.some((chip) => chip.setId)) return null;
const [
sets,
abilityLinks,
focusAbilities,
primaryOptions,
primaryValues,
secondaryOptions,
secondaryValues,
] = await Promise.all([
(
await database.prepare(
`SELECT chip.id,chip.specialization_ability_id
FROM skill_chip_sets chip
JOIN specialization_abilities ability
ON ability.id=chip.specialization_ability_id AND ability.active=1
WHERE chip.active=1`,
)
).all(),
(
await database.prepare(
`SELECT link.ability_id,link.compatible_ability_id
FROM specialization_ability_skill_chip_links link
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
JOIN specialization_abilities compatible ON compatible.id=link.compatible_ability_id AND compatible.active=1`,
)
).all(),
(
await database.prepare(
`SELECT link.ability_id,link.ability_slot
FROM specialization_focus_abilities link
JOIN specialization_focuses focus ON focus.id=link.focus_id AND focus.active=1
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
WHERE link.focus_id=? ORDER BY link.ability_slot`,
)
).all(loadout.specialization.focusId),
(
await database.prepare(
'SELECT stat_attribute_id FROM skill_chip_primary_options',
)
).all(),
(
await database.prepare('SELECT * FROM skill_chip_primary_quality_values')
).all(),
(
await database.prepare(
'SELECT stat_attribute_id FROM skill_chip_secondary_options',
)
).all(),
(
await database.prepare(
'SELECT * FROM skill_chip_secondary_quality_values',
)
).all(),
]);
const primaryIds = new Set(
primaryOptions.map((row) => String(row.stat_attribute_id)),
);
const secondaryIds = new Set(
secondaryOptions.map((row) => String(row.stat_attribute_id)),
);
const rangeFor = (rows: any[], statId: string, quality: string) =>
rows.find(
(row) =>
String(row.stat_attribute_id) === statId && row.quality === quality,
);
const inRange = (value: number, range: any) =>
range &&
value >= Number(range.min_value) &&
value <= Number(range.max_value);
for (let index = 0; index < loadout.chips.length; index += 1) {
const chip = loadout.chips[index];
if (!chip.setId) continue;
const definition = sets.find((row) => String(row.id) === chip.setId);
const expectedAbility = focusAbilities.find(
(row) => Number(row.ability_slot) === Math.floor(index / 3) + 1,
);
const ownerAbilityId = String(definition?.specialization_ability_id ?? '');
const expectedAbilityId = String(expectedAbility?.ability_id ?? '');
const sharedWithExpectedAbility = abilityLinks.some((link) => {
const left = String(link.ability_id);
const right = String(link.compatible_ability_id);
return (
(left === ownerAbilityId && right === expectedAbilityId) ||
(left === expectedAbilityId && right === ownerAbilityId)
);
});
if (
!definition ||
!expectedAbility ||
(ownerAbilityId !== expectedAbilityId && !sharedWithExpectedAbility)
)
return `Skill Chip ${index + 1} is not compatible with its assigned ability.`;
const primaryRange = rangeFor(
primaryValues,
chip.primary.statId,
chip.quality,
);
if (
!primaryIds.has(chip.primary.statId) ||
!inRange(chip.primary.value, primaryRange)
)
return `Skill Chip ${index + 1} has an invalid primary Attribute value.`;
if (
new Set(chip.secondary.map((roll) => roll.statId)).size !==
chip.secondary.length
)
return `Skill Chip ${index + 1} must use distinct secondary Attributes.`;
for (const roll of chip.secondary) {
const range = rangeFor(secondaryValues, roll.statId, chip.quality);
if (!secondaryIds.has(roll.statId) || !inRange(roll.value, range))
return `Skill Chip ${index + 1} has an invalid secondary Attribute value.`;
}
}
return null;
}
async function validateEquipmentTalentSelections(
database: Awaited<ReturnType<typeof getDatabase>>,
loadout: LoadoutDraft,
) {
const [gearTalents, weaponTalents, weapons, talentTiers] = await Promise.all([
(
await database.prepare('SELECT gear_id,talent_id FROM gear_talents')
).all(),
(
await database.prepare(
'SELECT weapon_id,talent_slot,talent_id FROM weapon_talent_options',
)
).all(),
(await database.prepare('SELECT id,exotic_talent_id FROM weapons')).all(),
(await database.prepare('SELECT talent_id,tier FROM talent_tiers')).all(),
]);
const hasTier = (talentId: string, tier: 1 | 2) =>
talentTiers.some(
(entry) =>
String(entry.talent_id) === talentId &&
String(entry.tier) === (tier === 2 ? 'II' : 'I'),
);
for (const slot of Object.keys(loadout.gear)) {
const selection = loadout.gear[slot];
if (!selection.talentId) continue;
if (
!gearTalents.some(
(entry) =>
String(entry.gear_id) === selection.itemId &&
String(entry.talent_id) === selection.talentId,
)
)
return `${slot} has a Talent that is not available for the equipped item.`;
if (!hasTier(selection.talentId, selection.talentTier))
return `${slot} Talent T${selection.talentTier} is not configured.`;
}
for (let index = 0; index < loadout.weapons.length; index += 1) {
const selection = loadout.weapons[index];
const label = index ? 'Secondary weapon' : 'Primary weapon';
for (const configured of [
{ id: selection.talent1Id, tier: selection.talent1Tier, slot: 1 },
{ id: selection.talent2Id, tier: selection.talent2Tier, slot: 2 },
]) {
if (!configured.id) continue;
if (
!weaponTalents.some(
(entry) =>
String(entry.weapon_id) === selection.itemId &&
Number(entry.talent_slot) === configured.slot &&
String(entry.talent_id) === configured.id,
)
)
return `${label} has a Talent that is not available in slot ${configured.slot}.`;
if (!hasTier(configured.id, configured.tier))
return `${label} Talent ${configured.slot} T${configured.tier} is not configured.`;
}
const weapon = weapons.find(
(entry) => String(entry.id) === selection.itemId,
);
const exoticTalentId = String(weapon?.exotic_talent_id ?? '');
if (exoticTalentId && !hasTier(exoticTalentId, selection.exoticTalentTier))
return `${label} exotic Talent T${selection.exoticTalentTier} is not configured.`;
}
return null;
}
export const load: PageServerLoad = async ({ cookies, url }) => {
const database = await getDatabase();
const viewer = await getSessionUser(cookies);
@@ -67,14 +259,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
talentTiers,
talentEffects,
talentTemplates,
conditionalRules,
conditionalPools,
conditionalClauses,
conditionalOutcomes,
chipSets,
abilitySkillChipLinks,
chipTwoPiece,
chipPrimary,
chipSecondary,
chipPrimaryOptions,
chipPrimaryQualityValues,
chipSecondaryOptions,
chipSecondaryQualityValues,
chipTalents,
chipTalentEffects,
protocols,
@@ -94,15 +285,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
specializationRanks,
specializationRankEffects,
specializationRankCosts,
specializationCombatEffects,
specializationRankCombatEffects,
specializationCombatModifiers,
progression,
rankProfiles,
activeCalculationRevision,
calculationStages,
calculationProfiles,
skillCalculationProfiles,
] = await Promise.all([
(
await database.prepare(
@@ -164,37 +348,41 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(
await database.prepare('SELECT * FROM talent_description_templates')
).all(),
(
await database.prepare(
'SELECT * FROM conditional_effect_rules WHERE configured=1',
)
).all(),
(
await database.prepare(
'SELECT * FROM conditional_effect_state_pools ORDER BY rule_id,sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM conditional_effect_clauses WHERE active=1 ORDER BY rule_id,sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM conditional_effect_outcomes ORDER BY rule_id,variant_key,outcome_key,sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
`SELECT link.ability_id,link.compatible_ability_id
FROM specialization_ability_skill_chip_links link
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
JOIN specialization_abilities compatible ON compatible.id=link.compatible_ability_id AND compatible.active=1`,
)
).all(),
(
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
).all(),
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
(
await database.prepare('SELECT * FROM skill_chip_secondary_options')
await database.prepare(
'SELECT stat_attribute_id FROM skill_chip_primary_options ORDER BY stat_attribute_id',
)
).all(),
(
await database.prepare(
'SELECT stat_attribute_id,quality,min_value,max_value FROM skill_chip_primary_quality_values ORDER BY stat_attribute_id,quality',
)
).all(),
(
await database.prepare(
'SELECT stat_attribute_id FROM skill_chip_secondary_options ORDER BY stat_attribute_id',
)
).all(),
(
await database.prepare(
'SELECT stat_attribute_id,quality,min_value,max_value FROM skill_chip_secondary_quality_values ORDER BY stat_attribute_id,quality',
)
).all(),
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
(
@@ -271,21 +459,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'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'",
@@ -296,26 +469,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'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(),
(
await database.prepare(`SELECT profile.* FROM skill_attribute_calculation_profiles profile
JOIN calculation_revisions revision ON revision.id=profile.revision_id
WHERE revision.status='active' ORDER BY profile.stage_key,profile.stat_attribute_id`)
).all(),
]);
const iconUrl = (
type:
@@ -333,26 +486,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
);
return icon ? gameDataIconUrl(type, String(id), icon.updated_at) : '';
};
const hydrateRule = (sourceType: string, sourceId: unknown) => {
const rule = conditionalRules.find(
(item) => item.source_type === sourceType && item.source_id === sourceId,
);
return rule
? {
rule,
statePools: conditionalPools.filter(
(pool) => pool.rule_id === rule.id,
),
clauses: conditionalClauses.filter(
(clause) => clause.rule_id === rule.id,
),
outcomes: conditionalOutcomes.filter(
(outcome) => outcome.rule_id === rule.id,
),
triggers: [],
}
: null;
};
const configuredTalents = talents.map((talent) => ({
...talent,
iconUrl: iconUrl('talent', talent.id),
@@ -367,10 +500,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(effect) => effect.talent_tier_id === tier.id,
),
})),
rule: hydrateRule('talent', talent.id),
}));
return {
viewer: viewer ? { id: viewer.id, displayName: viewer.displayName } : null,
publicOrigin: publicOrigin(url.origin),
initialBuild,
attributes,
coreRanges,
@@ -414,29 +547,46 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
weaponBonuses,
),
talents: configuredTalents,
skillChipProfile: {
primaryOptions: hydrateSkillChipProfileRows(
chipPrimaryOptions,
chipPrimaryQualityValues,
),
secondaryOptions: hydrateSkillChipProfileRows(
chipSecondaryOptions,
chipSecondaryQualityValues,
),
},
chipSets: chipSets.map((set) => {
const talent = chipTalents.find(
(item) => item.skill_chip_set_id === set.id,
);
return {
...set,
category:
specializationAbilities.find(
(ability) => ability.id === set.specialization_ability_id,
)?.name ?? 'SKILL CHIP SET',
compatibleAbilityIds: [
String(set.specialization_ability_id),
...abilitySkillChipLinks.flatMap((link) =>
link.ability_id === set.specialization_ability_id
? [String(link.compatible_ability_id)]
: link.compatible_ability_id === set.specialization_ability_id
? [String(link.ability_id)]
: [],
),
],
iconUrl: iconUrl('skill_chip', set.id),
twoPiece:
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
null,
primaryStatIds: chipPrimary
.filter((item) => item.skill_chip_set_id === set.id)
.map((item) => String(item.stat_attribute_id)),
secondaryStatIds: chipSecondary
.filter((item) => item.skill_chip_set_id === set.id)
.map((item) => String(item.stat_attribute_id)),
talent: talent
? {
...talent,
effects: chipTalentEffects.filter(
(effect) => effect.talent_id === talent.id,
),
rule: hydrateRule('skill_chip_talent', talent.id),
}
: null,
};
@@ -467,7 +617,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
effects: protocolTalentEffects.filter(
(effect) => effect.talent_id === talent.id,
),
rule: hydrateRule('os_protocol_talent', talent.id),
}
: null,
};
@@ -520,24 +669,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
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,
),
rule: hydrateRule('specialization_tree_rank', effect.id),
})),
})),
}));
return { ...focus, abilities: assignedAbilities, nodes };
@@ -550,12 +681,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
Number(progression?.shd_cache_points ?? 19),
rankProfiles,
},
calculation: {
revision: activeCalculationRevision,
stages: calculationStages,
profiles: calculationProfiles,
skillProfiles: skillCalculationProfiles,
},
};
};
@@ -584,6 +709,14 @@ export const actions: Actions = {
return fail(400, { buildError: 'Choose a valid build visibility.' });
const database = await getDatabase();
const equipmentTalentError = await validateEquipmentTalentSelections(
database,
loadout,
);
if (equipmentTalentError)
return fail(400, { buildError: equipmentTalentError });
const skillChipError = await validateSkillChipSelections(database, loadout);
if (skillChipError) return fail(400, { buildError: skillChipError });
const now = new Date().toISOString();
let buildId = id;
let slug = '';
+278 -337
View File
@@ -8,17 +8,18 @@
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 BuildShareControl from '$lib/components/BuildShareControl.svelte';
import LoadoutMatrix, { type MatrixCell } from '$lib/components/builder/LoadoutMatrix.svelte';
import WeaponCalculationResult from '$lib/components/builder/WeaponCalculationResult.svelte';
import SkillCalculationResult from '$lib/components/builder/SkillCalculationResult.svelte';
import { evaluateLoadoutCombat, evaluateLoadoutSkillCombat, summarizeLoadout } from '$lib/builder/loadout-combat';
import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte';
import WeaponBaselineThroughput from '$lib/components/builder/WeaponBaselineThroughput.svelte';
import PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte';
import TalentTierChoice from '$lib/components/builder/TalentTierChoice.svelte';
import { summarizeLoadout } from '$lib/builder/loadout-summary';
import { calculateWeaponBaseline } from '$lib/builder/weapon-baseline';
import { toast } from '$lib/toast';
import { MECHANIC_EVENT_REGISTRY, type MechanicEventKind } from '$lib/calculations/mechanic';
import {
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, weaponBaseRange,
type LoadoutDraft, type Roll
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, migrateLoadoutDraftV5, parseLoadoutDraft, remapRangeValue, scaled, skillChipQualities, skillChipSecondaryCapacity, weaponBaseRange,
type LoadoutDraft, type Roll, type SkillChipQuality, type TalentTier
} from '$lib/builder/loadout';
let { data, form } = $props();
@@ -28,17 +29,38 @@
let message = $state('');
let persistedBuildId = $state(untrack(() => data.initialBuild?.id ?? ''));
let persistedSlug = $state(untrack(() => data.initialBuild?.slug ?? ''));
let persistedVisibility = $state(untrack(() => data.initialBuild?.visibility ?? 'private'));
type BuildVisibility = 'private' | 'unlisted' | 'public';
let persistedVisibility = $state<BuildVisibility>(untrack(() => (data.initialBuild?.visibility ?? 'private') as BuildVisibility));
let savedBuildVisibility = $state<BuildVisibility>(untrack(() => (data.initialBuild?.visibility ?? 'private') as BuildVisibility));
let savedBuildTitle = $state(untrack(() => draft.buildName));
let handledBuildToken = '';
const weaponRangeKeys = ['damage','firepower','toughness','engineering'] as const;
const coreKeys = ['firepower','toughness','engineering'] as const;
const gearAttributes = $derived(data.attributes.filter((item) => item.gear_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
const weaponAttributes = $derived(data.attributes.filter((item) => item.weapon_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
const chipAttributes = $derived(data.attributes.filter((item) => item.skill_chip_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
const attribute = (id: string) => data.attributes.find((item) => item.id === id);
const chipPrimaryAttributes = $derived(data.skillChipProfile.primaryOptions.map((option: any) => attribute(option.statId)).filter(Boolean).map((item: any) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
const chipSecondaryAttributes = $derived(data.skillChipProfile.secondaryOptions.map((option: any) => attribute(option.statId)).filter(Boolean).map((item: any) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
const chipRange = (kind: 'primary' | 'secondary', statId: string, quality: SkillChipQuality) =>
data.skillChipProfile[`${kind}Options`].find((option: any) => option.statId === statId)?.ranges?.[quality] ?? null;
type ChipRange = { min: number; max: number };
const validChipRange = (range: any): range is ChipRange => Number.isFinite(Number(range?.min)) && Number.isFinite(Number(range?.max)) && Number(range.max) >= Number(range.min);
const chipRangeMin = (kind: 'primary' | 'secondary', statId: string, quality: SkillChipQuality) => Number(chipRange(kind, statId, quality)?.min ?? 0);
const chipRangeMax = (kind: 'primary' | 'secondary', statId: string, quality: SkillChipQuality) => Number(chipRange(kind, statId, quality)?.max ?? 0);
const chipProfileConfigured = $derived(
data.skillChipProfile.primaryOptions.length === 4
&& chipPrimaryAttributes.length === data.skillChipProfile.primaryOptions.length
&& data.skillChipProfile.primaryOptions.every((option: any) => skillChipQualities.every((quality) => validChipRange(option.ranges?.[quality])))
&& data.skillChipProfile.secondaryOptions.length >= 2
&& chipSecondaryAttributes.length === data.skillChipProfile.secondaryOptions.length
&& data.skillChipProfile.secondaryOptions.every((option: any) => ['Specialized','Superior','High-end'].every((quality) => validChipRange(option.ranges?.[quality])))
);
const gearItem = (slot: string) => data.gear.find((item) => item.id === draft.gear[slot].itemId);
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 chipSupportsAbility = (item: any, abilityId: string) =>
item?.compatibleAbilityIds?.includes(abilityId)
?? item?.specialization_ability_id === abilityId;
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 }))
@@ -54,12 +76,9 @@
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 ?? [])
.filter((effect: any) => Number(effect.rule?.configured) === 1)
.map((effect: any) => ({ ...effect, nodeName: node.name }))
));
const talent = (id: string) => data.talents.find((item) => item.id === id);
const supportedTalentTier = (id: string, tier: TalentTier): TalentTier =>
talent(id)?.tiers.some((entry: any) => entry.tier === (tier === 2 ? 'II' : 'I')) ? tier : 1;
const brand = (id: string) => data.brands.find((item) => item.id === id);
const unitValue = (value: unknown, statId: string) => {
const numeric = Number(value);
@@ -67,69 +86,6 @@
const unit = attribute(statId)?.unit ?? '';
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
};
const jsonObject = (value: unknown) => {
if (value && typeof value === 'object' && !Array.isArray(value)) return value as Record<string, any>;
if (typeof value !== 'string') return {};
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, any>
: {};
} catch {
return {};
}
};
const mechanicScenarioInputs = (rule: any) => {
const raw = rule?.rule?.scenario_inputs_json;
if (Array.isArray(raw)) return raw;
if (typeof raw !== 'string') return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
const combatEffectDescription = (effect: any) => {
const template = String(effect.definition?.description_template ?? effect.definition?.name ?? 'Conditional effect');
const rule = effect.rule;
const outcomes = (rule?.outcomes ?? []).filter((outcome: any) => outcome.variant_key === 'base');
const firstReducer = jsonObject(rule?.clauses?.[0]?.reducer_json);
const primaryPool = rule?.statePools?.[0];
return template
.replaceAll('{triggerCount}', String(firstReducer.threshold ?? ''))
.replaceAll('{triggerWindow}', firstReducer.windowSeconds ? `${firstReducer.windowSeconds}s` : '')
.replaceAll('{duration}', String(primaryPool?.duration_seconds ?? 0))
.replaceAll('{maxStacks}', String(primaryPool?.maximum_value ?? 1))
.replaceAll('{effectName}', String(effect.definition?.name ?? 'Effect'))
.replace(/\{(\d+)\}/g, (placeholder: string, raw: string) => {
const outcome = outcomes[Number(raw) - 1];
return outcome?.stat_attribute_id
? unitValue(outcome.value, outcome.stat_attribute_id)
: placeholder;
});
};
const conditionalFacts = (rule: any) => {
const clauses = rule?.clauses ?? [];
const eventLabels = [...new Set(clauses.map((clause: any) => {
const event = String(clause.event_kind ?? '') as MechanicEventKind;
return MECHANIC_EVENT_REGISTRY[event]?.label;
}).filter(Boolean))];
const readiness = String(rule?.rule?.simulation_readiness ?? 'captured_only')
.replaceAll('_', ' ')
.toUpperCase();
const cooldowns = clauses
.map((clause: any) => Number(clause.cooldown_seconds ?? 0))
.filter((value: number) => value > 0);
const poolMaximum = Math.max(0, ...(rule?.statePools ?? [])
.map((pool: any) => Number(pool.maximum_value ?? 0)));
return [
`${clauses.length} BRANCH${clauses.length === 1 ? '' : 'ES'}`,
eventLabels.length ? eventLabels.join(' · ').toUpperCase() : 'NO EVENT BRANCHES',
poolMaximum > 1 ? `MAX ${poolMaximum} STACKS` : readiness,
cooldowns.length ? `${Math.max(...cooldowns)}s COOLDOWN` : 'NO COOLDOWN'
];
};
const scaledRange = (minimum: unknown, maximum: unknown, level: unknown, referenceLevel: unknown, source: 'gear' | 'weapon') =>
levelScaledRange(minimum, maximum, level, referenceLevel ?? 50, source);
const gearBaseRange = (item: any, key: 'health' | 'armor', level: number) =>
@@ -147,13 +103,36 @@
coreKeys.reduce((total, key) => total + Number(selection[key] ?? 0), 0);
const attributeRange = (stat: any, source: 'gear' | 'weapon', level: number) =>
scaledRange(stat?.[`${source}_min`], stat?.[`${source}_max`], level, stat?.[`${source}_reference_level`], source);
const setRollStat = (roll: Roll, id: string, source: 'gear' | 'weapon' | 'chip', level = 50) => {
const setRollStat = (roll: Roll, id: string, source: 'gear' | 'weapon', level = 50) => {
roll.statId = id;
const stat = attribute(id);
const min = source === 'chip'
? stat?.skill_chip_min
: attributeRange(stat, source, level).min;
roll.value = Number(min ?? 0);
roll.value = Number(attributeRange(stat, source, level).min ?? 0);
};
const setChipRoll = (roll: Roll, id: string, kind: 'primary' | 'secondary', quality: SkillChipQuality) => {
roll.statId = id;
const range = chipRange(kind, id, quality);
roll.value = validChipRange(range) ? Number(range.min) : 0;
};
const chipRollConfigured = (roll: Roll, kind: 'primary' | 'secondary', quality: SkillChipQuality) => {
const options = data.skillChipProfile[`${kind}Options`];
const range = chipRange(kind, roll.statId, quality);
const value = Number(roll.value);
return options.some((option: any) => option.statId === roll.statId)
&& validChipRange(range)
&& Number.isFinite(value)
&& value >= Number(range.min)
&& value <= Number(range.max);
};
const chipSelectionConfigured = (chip: LoadoutDraft['chips'][number], abilityId: string) => {
const capacity = skillChipSecondaryCapacity[chip.quality];
const secondaryIds = chip.secondary.map((roll) => roll.statId);
const definition = data.chipSets.find((item: any) => item.id === chip.setId);
return Boolean(definition && chipSupportsAbility(definition, abilityId))
&& chipProfileConfigured
&& chipRollConfigured(chip.primary, 'primary', chip.quality)
&& chip.secondary.length === capacity
&& new Set(secondaryIds).size === secondaryIds.length
&& chip.secondary.every((roll) => chipRollConfigured(roll, 'secondary', chip.quality));
};
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) => {
@@ -198,14 +177,14 @@
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)) {
if (chip.setId && (!ability || !chipSupportsAbility(definition, ability.id))) {
displaced.push(structuredClone(chip));
draft.chips[index] = createDraft().chips[index];
}
}
for (const chip of displaced) {
const definition = data.chipSets.find((item: any) => item.id === chip.setId);
const abilityIndex = next.abilities.findIndex((ability: any) => ability.id === definition?.specialization_ability_id);
const abilityIndex = next.abilities.findIndex((ability: any) => chipSupportsAbility(definition, 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;
@@ -218,20 +197,6 @@
};
active = 'specialization:focus';
};
const toggleTalent = (key: string) => {
const current = draft.conditionalEffects[key]?.mode ?? 'off';
draft.conditionalEffects = {
...draft.conditionalEffects,
[key]: { mode: current === 'off' ? 'peak' : 'off' }
};
};
const setSkillActivation = (abilityId: string, patch: Record<string, unknown>) => {
const current = draft.skillActivations[abilityId] ?? { mode: 'off', targets: 1 };
draft.skillActivations = {
...draft.skillActivations,
[abilityId]: { ...current, ...patch }
};
};
const talentTier = (talentId: string, tier: number) => {
const item = talent(talentId);
return item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ?? item?.tiers[0];
@@ -251,6 +216,7 @@
selection.level = 50;
selection.brandId = item?.brandIds[0] ?? '';
selection.talentId = item?.talentIds[0] ?? '';
selection.talentTier = supportedTalentTier(selection.talentId, Number(item?.tier) >= 2 ? 2 : 1);
selection.health = Number(item?.base_health_min ?? 0);
selection.armor = Number(item?.base_armor_min ?? 0);
const core = coreAllocationRange(Number(item?.tier ?? 0), selection.level, 'gear');
@@ -299,19 +265,48 @@
const core = coreAllocationRange(selection.tier, selection.level, 'weapon');
for (const key of coreKeys) selection[key] = core.min;
selection.talent1Id = item?.talent1Ids[0] ?? '';
selection.talent1Tier = supportedTalentTier(selection.talent1Id, selection.tier >= 2 ? 2 : 1);
selection.talent2Id = item?.talent2Ids[0] ?? '';
selection.talent2Tier = supportedTalentTier(selection.talent2Id, selection.tier >= 2 ? 2 : 1);
selection.exoticTalentTier = supportedTalentTier(item?.exotic_talent_id ?? '', selection.tier >= 2 ? 2 : 1);
selection.rolls = [blankRoll(), blankRoll()];
};
const configureChip = (index: number, id: string) => {
const chip = draft.chips[index], item = data.chipSets.find((entry) => entry.id === id);
const chip = draft.chips[index];
chip.setId = id;
chip.primary = { statId: item?.primaryStatIds[0] ?? '', value: 0 };
chip.secondary = [
{ statId: item?.secondaryStatIds[0] ?? '', value: 0 },
{ statId: item?.secondaryStatIds[1] ?? '', value: 0 }
];
if (chip.primary.statId) setRollStat(chip.primary, chip.primary.statId, 'chip');
for (const roll of chip.secondary) if (roll.statId) setRollStat(roll, roll.statId, 'chip');
chip.primary = blankRoll();
chip.secondary = [];
changeChipQuality(index, chip.quality);
};
const changeChipQuality = (index: number, quality: SkillChipQuality) => {
if (!skillChipQualities.includes(quality)) return;
const chip = draft.chips[index];
chip.quality = quality;
const primaryOption = data.skillChipProfile.primaryOptions.find((option: any) => option.statId === chip.primary.statId && validChipRange(option.ranges?.[quality]))
?? data.skillChipProfile.primaryOptions.find((option: any) => validChipRange(option.ranges?.[quality]));
if (primaryOption) {
const range = primaryOption.ranges[quality];
chip.primary = {
statId: primaryOption.statId,
value: clamp(chip.primary.statId === primaryOption.statId ? chip.primary.value : range.min, range.min, range.max)
};
} else chip.primary = blankRoll();
const capacity = skillChipSecondaryCapacity[quality];
const next: Roll[] = [];
for (const roll of chip.secondary) {
if (next.length >= capacity || next.some((entry) => entry.statId === roll.statId)) continue;
const range = chipRange('secondary', roll.statId, quality);
if (!validChipRange(range)) continue;
next.push({ statId: roll.statId, value: clamp(roll.value, range.min, range.max) });
}
for (const option of data.skillChipProfile.secondaryOptions) {
if (next.length >= capacity) break;
if (next.some((entry) => entry.statId === option.statId)) continue;
const range = option.ranges?.[quality];
if (validChipRange(range)) next.push({ statId: option.statId, value: Number(range.min) });
}
while (next.length < capacity) next.push(blankRoll());
chip.secondary = next;
};
const changeWeaponTier = (index: number, tier: number) => {
const selection = draft.weapons[index], item = weaponItem(index);
@@ -346,43 +341,28 @@
selection.level = nextLevel;
};
const activeParts = $derived(active.split(':'));
const talentCooldown = (id: string) => {
const value = talent(id)?.cooldown_seconds;
if (value === null || value === undefined || value === '') return '';
return `Talent Cooldown: ${value} ${Number(value) === 1 ? 'second' : 'seconds'}`;
};
const summary = $derived(summarizeLoadout(draft, data));
const modeledTalents = $derived(summary.equippedTalents.filter(
(equipped: any) => Number(equipped.rule?.rule?.configured) === 1
&& (equipped.rule?.clauses?.length ?? 0) > 0
));
const calculationDraft = $derived.by(() => {
if (draft.calculationMode === 'custom') return draft;
const effectKeys = [
...selectedCombatEffects.map((effect: any) => String(effect.id)),
...modeledTalents.map((effect: any) => String(effect.key))
];
const conditionalEffects = {
...draft.conditionalEffects,
...Object.fromEntries(effectKeys.map((key) => [
key,
{ ...draft.conditionalEffects[key], mode: 'peak' as const }
]))
const weaponBaselines = $derived(draft.weapons.map((selection, index) => {
const item = data.weapons.find((entry) => entry.id === selection.itemId);
return {
slot: index ? 'SECONDARY' as const : 'PRIMARY' as const,
id: String(item?.id ?? ''),
name: item?.name ?? 'UNASSIGNED',
damage: Number.isFinite(Number(selection.damage)) ? Number(selection.damage) : null,
rpm: item?.rpm == null ? null : Number(item.rpm),
magazine: item?.magazine == null ? null : Number(item.magazine),
reloadSpeed: item?.reload_speed == null ? null : Number(item.reload_speed),
baseline: calculateWeaponBaseline({
weaponId: String(item?.id ?? 'NO CATALOG ID'),
weaponName: item?.name,
weaponLabel: index ? 'SECONDARY' : 'PRIMARY',
selectedDamage: selection.damage,
rpm: item?.rpm,
magazine: item?.magazine,
reloadSeconds: item?.reload_speed
})
};
const skillActivations = { ...draft.skillActivations };
for (const ability of selectedFocus?.abilities ?? []) {
skillActivations[ability.id] = {
mode: 'on_cooldown',
targets: Math.max(1, Number(ability.attack?.target_cap ?? 1))
};
}
return { ...draft, conditionalEffects, skillActivations };
});
const combatResults = $derived(evaluateLoadoutCombat(calculationDraft, data, summary));
const skillCombatResult = $derived(evaluateLoadoutSkillCombat(calculationDraft, data, summary));
const skillAbilityNames = $derived(Object.fromEntries(
(selectedFocus?.abilities ?? []).map((ability: any) => [String(ability.id), String(ability.name)])
));
}));
const rollSummary = (rolls: Roll[]) => rolls
.filter((roll) => roll.statId)
.map((roll) => `${attribute(roll.statId)?.name ?? 'Attribute'} ${unitValue(roll.value, roll.statId)}`)
@@ -399,45 +379,49 @@
const value = String(category ?? '').toLowerCase();
if (value.includes('shotgun')) return '/shotgun.png';
if (value.includes('smg') || value.includes('submachine')) return '/smg.png';
if (value.includes('mmr') || value.includes('marksman')) return '/mmr.png';
if (value.includes('lmg') || value.includes('light machine')) return '/lmg.png';
if (value.includes('ar') || value.includes('assault')) return '/ar.png';
return '/rifle.png';
};
const doctrineCell = $derived<MatrixCell>({
id: 'specialization:focus',
label: 'SPECIALIZATION + FOCUS',
name: selectedFocus ? `${selectedFocus.specialization.name} · ${selectedFocus.name}` : 'SELECT DOCTRINE',
label: 'FOCUS',
name: selectedFocus ? `${selectedFocus.specialization.name} · ${selectedFocus.name}` : 'SELECT FOCUS',
meta: selectedFocus?.primary_attribute ?? '',
summary: selectedFocus
? `${selectedFocus.primary_attribute} · ${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS`
: 'ROOT CHOICE · DEFINES ALL THREE ABILITIES',
: 'SETS 3 ABILITIES',
iconUrl: selectedFocus?.specialization.iconUrl,
issue: !selectedFocus ? 'REQUIRED FOR CALCULATION' : selectedFocus.abilities.length !== 3 ? 'ABILITY DATA INCOMPLETE' : undefined
issue: !selectedFocus ? 'Select a focus.' : selectedFocus.abilities.length !== 3 ? 'Focus needs 3 abilities.' : undefined
});
const abilityCells = $derived.by((): MatrixCell[] => [0, 1, 2].map((index) => {
const ability = selectedFocus?.abilities[index];
const configuredChips = draft.chips.slice(index * 3, index * 3 + 3).filter((chip) => chip.setId);
const abilityChips = draft.chips.slice(index * 3, index * 3 + 3);
const configuredChips = abilityChips.filter((chip) => chip.setId);
const chipNames = configuredChips.map((chip) => data.chipSets.find((item) => item.id === chip.setId)?.name).filter(Boolean);
return {
id: `ability:${index}`,
label: `ABILITY 0${index + 1}`,
name: ability?.name ?? 'NOT ASSIGNED',
label: `ABILITY ${index + 1}`,
name: ability?.name ?? 'UNASSIGNED',
meta: ability?.attack?.damage_type ?? '',
summary: chipNames.length ? chipNames.join(' · ') : `${configuredChips.length} / 3 SKILL CHIPS`,
iconUrl: ability?.iconUrl,
issue: !ability ? 'ABILITY NOT CONFIGURED' : configuredChips.length < 3 ? `${3 - configuredChips.length} CHIP${configuredChips.length === 2 ? '' : 'S'} REQUIRED` : undefined
issue: !ability ? 'Ability required.' : configuredChips.length < 3 ? `${3 - configuredChips.length} chip${configuredChips.length === 2 ? '' : 's'} required.` : abilityChips.some((chip) => !chipSelectionConfigured(chip, ability.id)) ? 'Chip setup required.' : undefined
};
}));
const weaponCells = $derived.by((): MatrixCell[] => draft.weapons.map((selection, index) => {
const item = weaponItem(index);
const talents = [selection.talent1Id, selection.talent2Id].map((id) => talent(id)?.name).filter(Boolean);
const talents = [[selection.talent1Id, selection.talent1Tier], [selection.talent2Id, selection.talent2Tier]].map(([id,tier]) => id ? `${talent(String(id))?.name ?? 'Talent'} T${tier}` : '').filter(Boolean);
const details = [...talents, rollSummary(selection.rolls)].filter(Boolean).join(' · ');
return {
id: `weapon:${index}`,
label: `${index ? 'SECONDARY' : 'PRIMARY'} WEAPON`,
name: item?.name ?? 'EMPTY SLOT',
label: index ? 'SECONDARY' : 'PRIMARY',
name: item?.name ?? 'EMPTY',
meta: item?.category ?? '',
summary: item ? details || `T${selection.tier} · ${unitValue(selection.damage, '')} DAMAGE` : 'SELECT WEAPON',
summary: item ? details || `T${selection.tier} · ${unitValue(selection.damage, '')} DAMAGE` : 'CHOOSE WEAPON',
iconUrl: weaponIcon(item?.category),
issue: item ? undefined : 'REQUIRED FOR CALCULATION'
issue: item ? undefined : 'Choose a weapon.'
};
}));
const gearCells = $derived.by((): MatrixCell[] => gearSlots.map((slot) => {
@@ -445,69 +429,64 @@
const item = gearItem(slot);
const details = [
brand(selection.brandId)?.name,
talent(selection.talentId)?.name,
selection.talentId ? `${talent(selection.talentId)?.name ?? 'Talent'} T${selection.talentTier}` : '',
rollSummary(selection.rolls)
].filter(Boolean).join(' · ');
return {
id: `gear:${slot}`,
label: slot.toUpperCase(),
name: item?.name ?? 'EMPTY SLOT',
name: item?.name ?? 'EMPTY',
meta: item ? `T${item.tier}` : '',
summary: item ? details || `T${item.tier} · LEVEL ${selection.level}` : 'SELECT GEAR',
summary: item ? details || `T${item.tier} · LEVEL ${selection.level}` : 'CHOOSE GEAR',
iconUrl: gearIcon(slot),
issue: item ? undefined : 'REQUIRED FOR CALCULATION'
issue: item ? undefined : 'Choose gear.'
};
}));
const protocolCell = $derived<MatrixCell>({
id: 'protocol:0',
label: 'OS PROTOCOL',
name: protocolItem()?.name ?? 'EMPTY SLOT',
label: 'PROTOCOL',
name: protocolItem()?.name ?? 'EMPTY',
meta: protocolItem()?.quality ?? '',
summary: protocolItem()
? `LEVEL ${draft.protocol.level} · ${draft.protocol.talentUpgrades} / 5 TALENT UPGRADES`
: 'SELECT ONE PROTOCOL',
: 'CHOOSE PROTOCOL',
iconUrl: protocolItem()?.iconUrl,
issue: protocolItem() ? undefined : 'REQUIRED FOR CALCULATION'
issue: protocolItem() ? undefined : 'Choose a protocol.'
});
const loadoutConfigured = $derived(Boolean(
selectedFocus
&& selectedFocus.abilities.length === 3
&& draft.weapons.every((item) => item.itemId)
&& gearSlots.every((slot) => draft.gear[slot].itemId)
&& draft.chips.every((item) => item.setId)
&& draft.chips.every((chip, index) => chipSelectionConfigured(
chip,
selectedFocus.abilities[Math.floor(index / 3)]?.id ?? ''
))
&& draft.protocol.itemId
));
$effect(() => {
const sources = [
...selectedCombatEffects.map((effect: any) => ({ key: String(effect.id) })),
...modeledTalents
];
const missing = sources.filter((source: any) => !draft.conditionalEffects[source.key]);
if (missing.length) draft.conditionalEffects = {
...draft.conditionalEffects,
...Object.fromEntries(missing.map((effect: any) => [effect.key, { mode: 'off' }]))
};
const missingAbilities = (selectedFocus?.abilities ?? []).filter((ability: any) => !draft.skillActivations[ability.id]);
if (missingAbilities.length) draft.skillActivations = {
...draft.skillActivations,
...Object.fromEntries(missingAbilities.map((ability: any) => [ability.id, { mode: 'off', targets: 1 }]))
};
});
onMount(() => {
if (data.initialBuild) return;
localStorage.removeItem('shd-build-loadout-draft-v3');
const stored = localStorage.getItem('shd-build-loadout-draft-v4');
localStorage.removeItem('shd-build-loadout-draft-v4');
if (data.initialBuild) return;
const current = localStorage.getItem('shd-build-loadout-draft-v6');
const legacy = current ? null : localStorage.getItem('shd-build-loadout-draft-v5');
const stored = current ?? legacy;
if (!stored) return;
try {
const parsed = parseLoadoutDraft(JSON.parse(stored));
const raw = JSON.parse(stored);
const parsed = current ? parseLoadoutDraft(raw) : migrateLoadoutDraftV5(raw, data);
if (parsed) {
draft = parsed;
if (legacy) {
localStorage.setItem('shd-build-loadout-draft-v6', JSON.stringify(parsed));
localStorage.removeItem('shd-build-loadout-draft-v5');
}
if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = '';
saved = true;
} else localStorage.removeItem('shd-build-loadout-draft-v4');
} else localStorage.removeItem(current ? 'shd-build-loadout-draft-v6' : 'shd-build-loadout-draft-v5');
} catch {
localStorage.removeItem('shd-build-loadout-draft-v4');
localStorage.removeItem(current ? 'shd-build-loadout-draft-v6' : 'shd-build-loadout-draft-v5');
}
});
$effect(() => {
@@ -515,7 +494,9 @@
handledBuildToken = form.buildToken;
persistedBuildId = form.savedBuild.id;
persistedSlug = form.savedBuild.slug;
persistedVisibility = form.savedBuild.visibility;
persistedVisibility = form.savedBuild.visibility as BuildVisibility;
savedBuildVisibility = form.savedBuild.visibility as BuildVisibility;
savedBuildTitle = form.savedBuild.title;
message = form.savedBuild.visibility === 'private'
? 'Private build saved to your account.'
: form.savedBuild.visibility === 'unlisted'
@@ -523,7 +504,7 @@
: 'Build published to the SHD archive.';
});
function saveDraft() {
localStorage.setItem('shd-build-loadout-draft-v4', JSON.stringify(draft));
localStorage.setItem('shd-build-loadout-draft-v6', JSON.stringify(draft));
saved = true;
toast.success('Draft saved locally', 'This loadout is available on this device.');
}
@@ -533,10 +514,10 @@
<main class="app-page builder-page">
<div class="builder-command">
<div class="page-title">
<div><p class="eyebrow"><span></span> COMMAND GRID / LOADOUT 01</p><input id="builder-build-name" name="builder-build-name" bind:value={draft.buildName} aria-label="Build name" maxlength="100"/></div>
<div><p class="eyebrow"><span></span> BUILDER / LOADOUT 01</p><input id="builder-build-name" name="builder-build-name" bind:value={draft.buildName} aria-label="Build name" maxlength="100"/></div>
<div class="actions">
<span class:visible={saved}>SAVED LOCALLY</span>
<button class="secondary" onclick={saveDraft}>SAVE LOCALLY</button>
<span class:visible={saved}>LOCAL SAVED</span>
<button class="secondary" onclick={saveDraft}>SAVE</button>
{#if data.viewer}
<PendingForm action="?/save" class="builder-save-form">
{#snippet children(pending)}
@@ -552,20 +533,21 @@
</div>
{#if data.viewer}
<div class="builder-persistence">
<SegmentedChoice legend="ACCOUNT VISIBILITY" name="build-visibility" options={[
<SegmentedChoice legend="VISIBILITY" name="build-visibility" options={[
{ value:'private', label:'PRIVATE' },
{ value:'unlisted', label:'UNLISTED' },
{ value:'public', label:'PUBLIC' }
]} value={persistedVisibility} onchange={(values) => persistedVisibility = values[0]} required/>
<p>{persistedVisibility === 'private' ? 'Only you can open this build.' : persistedVisibility === 'unlisted' ? 'Anyone with its direct link can open this build.' : 'Listed publicly and accessible by direct link.'}</p>
{#if persistedSlug && persistedVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
]} value={persistedVisibility} onchange={(values) => persistedVisibility = values[0] as BuildVisibility} required/>
<p>{persistedVisibility === 'private' ? 'Private to you.' : persistedVisibility === 'unlisted' ? 'Direct link only.' : 'Public listing and link.'}</p>
{#if persistedSlug && savedBuildVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
</div>
{/if}
<div class="builder-status"><span><i></i> LIVE CALCULATION</span><span>{draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : 'CUSTOM SCENARIO'}</span><span>12 LOADOUT CELLS + 9 CHIPS</span><span>{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS</span></div>
<div class="builder-status"><span><i></i> STATS ONLINE</span></div>
</div>
{#if message}<p class="success" role="status">{message}</p>{/if}
{#if form?.buildError}<p class="error" role="alert">{form.buildError}</p>{/if}
{#if !data.gear.length && !data.weapons.length && !data.chipSets.length && !data.protocols.length}<div class="builder-empty-data"><strong>ISAC DATA LINK EMPTY</strong><span>Add active equipment definitions in ISAC to populate this workbench.</span></div>{/if}
{#if data.viewer && persistedSlug}<BuildShareControl title={savedBuildTitle} slugOrUrl={new URL(`/builds/${encodeURIComponent(persistedSlug)}`, data.publicOrigin).toString()} visibility={savedBuildVisibility}/>{/if}
{#if !data.gear.length && !data.weapons.length && !data.chipSets.length && !data.protocols.length}<div class="builder-empty-data"><strong>NO BUILDER DATA</strong><span>Add active items in ISAC.</span></div>{/if}
<div class="loadout-workspace">
<LoadoutMatrix
{active}
@@ -580,185 +562,144 @@
<section class="loadout-editor">
{#if activeParts[0] === 'specialization'}
<div class="loadout-editor-head"><div><span>AGENT CLASS</span><h2>SPECIALIZATION FOCUS</h2></div><b>{selectedFocus ? `${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS` : 'UNASSIGNED'}</b></div>
<BuilderPicker items={specializationItems} value={draft.specialization.focusId} onchange={selectFocus} label="SPECIALIZATION + FOCUS"/>
<div class="loadout-editor-head"><div><span>SPECIALIZATION</span><h2>FOCUS</h2></div><b>{selectedFocus ? `${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS` : 'UNASSIGNED'}</b></div>
<BuilderPicker items={specializationItems} value={draft.specialization.focusId} onchange={selectFocus} label="FOCUS"/>
{#if selectedFocus}
<div class="builder-specialization-identity"><span class="focus-composite-icon"><ItemIcon src={selectedFocus.specialization.iconUrl} alt={selectedFocus.specialization.name}/><GameIcon name={selectedFocus.primary_attribute.toLocaleLowerCase() as GameIconName} decorative/></span><div><small>{selectedFocus.specialization.name}</small><h3>{selectedFocus.name}</h3><p>{selectedFocus.description || 'No focus description has been entered.'}</p></div><b class:configured={Number(selectedFocus.builder_ready) === 1}>{Number(selectedFocus.builder_ready) === 1 ? 'BUILDER READY' : 'ISAC DATA INCOMPLETE'}</b></div>
<div class="builder-ability-chip-panels">
{#each [0,1,2] as abilityIndex}
{@const ability = selectedFocus.abilities[abilityIndex]}
<section>
<header><ItemIcon src={ability?.iconUrl} alt={ability?.name ?? `Ability ${abilityIndex + 1}`}/><div><small>ABILITY {abilityIndex + 1}</small><strong>{ability?.name ?? 'NOT CONFIGURED'}</strong></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3 CHIPS</b></header>
<p>{ability?.description ?? 'ISAC requires this class ability before compatible Skill Chips can be equipped.'}</p>
<div>{#each [0,1,2] as chipSlot}{@const chipIndex = abilityIndex * 3 + chipSlot}{@const item = chipItem(chipIndex)}<button type="button" onclick={() => active = `chip:${chipIndex}`}><small>CHIP {chipSlot + 1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? draft.chips[chipIndex].quality : 'SELECT CHIP'}</span></button>{/each}</div>
</section>
{/each}
</div>
{#if draft.calculationMode === 'custom'}<div class="builder-skill-activation">
<div class="builder-section-title"><div><span>SKILL ACTIVATION PROFILE</span><small>ALL ENABLED ABILITIES CONTRIBUTE TO COMBINED SKILL DPS</small></div><b>{selectedFocus.abilities.filter((ability: any) => (draft.skillActivations[ability.id]?.mode ?? 'off') !== 'off').length} ENABLED</b></div>
{#each selectedFocus.abilities as ability}
{@const activation = draft.skillActivations[ability.id] ?? { mode: 'off', targets: 1 }}
<section>
<header><ItemIcon src={ability.iconUrl} alt=""/><div><strong>{ability.name}</strong><small>{ability.attack ? `${ability.attack.delivery.toUpperCase()} · ${ability.attack.damage_type}` : 'ATTACK PROFILE NOT CONFIGURED'}</small></div></header>
<SegmentedChoice legend={`${ability.name} activation`} name={`skill-${ability.id}-activation`} options={[{value:'off',label:'OFF'},{value:'on_cooldown',label:'ON COOLDOWN'},{value:'custom_per_minute',label:'CUSTOM / MIN'}]} value={activation.mode} onchange={(values) => setSkillActivation(ability.id,{mode:values[0]})}/>
<div class="builder-skill-assumptions">
{#if activation.mode === 'custom_per_minute'}<RangeValueControl label="Uses per minute" min={0.1} max={60} step={0.1} value={activation.usesPerMinute ?? 1} onchange={(value) => setSkillActivation(ability.id,{usesPerMinute:value})}/>{/if}
<RangeValueControl label="Targets hit" min={1} max={Math.max(1,Number(ability.attack?.target_cap ?? 1))} step={1} value={activation.targets} onchange={(value) => setSkillActivation(ability.id,{targets:value})}/>
</div>
</section>
{/each}
</div>{/if}
<div class="builder-specialization-telemetry"><span>POINTS <b>{selectedSpecializationPoints} / {data.specializationProgression.availablePoints}</b></span><span>CREDITS <b>{selectedSpecializationCosts.credits.toLocaleString()}</b></span>{#each selectedSpecializationCosts.resources as resource}<span>{String(resource[0]).replace('resource-electronics-','').replaceAll('-',' ').toUpperCase()} <b>× {resource[1]}</b></span>{/each}</div>
<div class="builder-focus-tree">
<div class="builder-section-title"><div><span>FOCUS UPGRADE TREE</span><small>PREVIOUS ROWS REQUIRE EVERY AVAILABLE RANK 1 + 2 BEFORE ADVANCING</small></div><b>{selectedFocus.nodes.length} NODES</b></div>
{#each [...new Set(selectedFocus.nodes.map((node: any) => Number(node.row_number)))] as row}
<section><header><span>ROW {String(row).padStart(2, '0')}</span><b>{row === 1 ? 'ENTRY ROW' : 'LOCKED BY PREVIOUS ROWS'}</b></header><div>
{#each selectedFocus.nodes.filter((node: any) => Number(node.row_number) === row) as node}
<article><ItemIcon src={node.iconUrl} alt={node.name}/><div><strong>{node.name}</strong><small>{node.description_template}</small></div><SegmentedChoice legend={`${node.name} rank`} name={`specialization-node-${node.id}`} options={[{value:'0',label:'OFF'},...Array.from({length:Number(node.max_rank)},(_,index)=>({value:String(index+1),label:`R${index+1}`,className:canSelectNodeRank(node,index+1)?'':'unavailable',disabled:!canSelectNodeRank(node,index+1)}))]} value={String(draft.specialization.nodeRanks[node.id] ?? 0)} onchange={(values) => setNodeRank(node,Number(values[0]))}/></article>
<div class="builder-specialization-layout">
<div class="builder-editor-lane">
<div class="builder-specialization-identity"><span class="focus-composite-icon"><ItemIcon src={selectedFocus.specialization.iconUrl} alt=""/><GameIcon name={selectedFocus.primary_attribute.toLocaleLowerCase() as GameIconName} decorative/></span><div><small>{selectedFocus.specialization.name}</small><h3>{selectedFocus.name}</h3><p>{selectedFocus.description || 'No focus description.'}</p></div><b class:configured={Number(selectedFocus.builder_ready) === 1}>{Number(selectedFocus.builder_ready) === 1 ? 'READY' : 'NEEDS DATA'}</b></div>
<div class="builder-focus-abilities" aria-label="Assigned abilities">
{#each [0,1,2] as abilityIndex}
{@const ability = selectedFocus.abilities[abilityIndex]}
<button type="button" onclick={() => active = `ability:${abilityIndex}`}><ItemIcon src={ability?.iconUrl} alt="" className="builder-ability-icon"/><span><small>ABILITY {abilityIndex + 1}</small><strong>{ability?.name ?? 'UNASSIGNED'}</strong></span><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3</b></button>
{/each}
</div></section>
{/each}
</div>
<div class="builder-specialization-telemetry"><span>POINTS <b>{selectedSpecializationPoints} / {data.specializationProgression.availablePoints}</b></span><span>CREDITS <b>{selectedSpecializationCosts.credits.toLocaleString()}</b></span>{#each selectedSpecializationCosts.resources as resource}<span>{String(resource[0]).replace('resource-electronics-','').replaceAll('-',' ').toUpperCase()} <b>× {resource[1]}</b></span>{/each}</div>
</div>
<div class="builder-focus-tree builder-editor-lane">
<div class="builder-section-title"><div><span>UPGRADE TREE</span><small>UNLOCK IN ORDER</small></div><b>{selectedFocus.nodes.length} NODES</b></div>
{#each [...new Set(selectedFocus.nodes.map((node: any) => Number(node.row_number)))] as row}
<section><header><span>ROW {String(row).padStart(2, '0')}</span><b>{row === 1 ? 'START' : 'PRIOR ROWS'}</b></header><div>
{#each selectedFocus.nodes.filter((node: any) => Number(node.row_number) === row) as node}
<article><ItemIcon src={node.iconUrl} alt={node.name}/><div><strong>{node.name}</strong><small>{node.description_template}</small></div><SegmentedChoice legend={`${node.name} rank`} name={`specialization-node-${node.id}`} options={[{value:'0',label:'OFF'},...Array.from({length:Number(node.max_rank)},(_,index)=>({value:String(index+1),label:`R${index+1}`,className:canSelectNodeRank(node,index+1)?'':'unavailable',disabled:!canSelectNodeRank(node,index+1)}))]} value={String(draft.specialization.nodeRanks[node.id] ?? 0)} onchange={(values) => setNodeRank(node,Number(values[0]))}/></article>
{/each}
</div></section>
{/each}
</div>
</div>
<CombatAssumptions bind:mode={draft.calculationMode} bind:profile={draft.combatProfile}/>
{#if selectedCombatEffects.length}
<div class="builder-conditional-effects">
<div class="builder-section-title"><div><span>CONDITIONAL EFFECT PROFILES</span><small>CHOOSE HOW EACH UNLOCKED EFFECT CONTRIBUTES TO DPS</small></div><b>{selectedCombatEffects.length} EFFECTS</b></div>
{#each selectedCombatEffects as effect}
<ConditionalEffectCard
id={effect.id}
name={effect.definition?.name ?? effect.nodeName}
description={combatEffectDescription(effect)}
facts={conditionalFacts(effect.rule)}
scenarioInputs={mechanicScenarioInputs(effect.rule)}
appliedState={draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : draft.conditionalEffects[effect.id]?.mode === 'peak' ? 'PEAK MODE' : draft.conditionalEffects[effect.id]?.mode === 'encounter' ? 'ENCOUNTER MODE' : 'NOT APPLIED'}
bind:effectState={draft.conditionalEffects[effect.id]}
calculationMode={draft.calculationMode}
/>
{/each}
</div>
{/if}
{#if modeledTalents.length}
<div class="builder-conditional-effects">
<div class="builder-section-title"><div><span>EQUIPPED TALENT MODELING</span><small>MODEL EACH MECHANIC PROGRAM OR EXCLUDE IT FROM CALCULATIONS</small></div><b>{modeledTalents.length} TALENTS</b></div>
{#each modeledTalents as equipped}
<ConditionalEffectCard
id={equipped.key}
name={equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}
description={equipped.description}
facts={conditionalFacts(equipped.rule)}
scenarioInputs={mechanicScenarioInputs(equipped.rule)}
appliedState={draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : draft.conditionalEffects[equipped.key]?.mode === 'peak' ? 'PEAK MODE' : draft.conditionalEffects[equipped.key]?.mode === 'encounter' ? 'ENCOUNTER MODE' : 'NOT APPLIED'}
bind:effectState={draft.conditionalEffects[equipped.key]}
calculationMode={draft.calculationMode}
/>
{/each}
</div>
{/if}
{/if}
{:else if activeParts[0] === 'ability'}
{@const abilityIndex = Number(activeParts[1])}
{@const ability = selectedFocus?.abilities[abilityIndex]}
<div class="loadout-editor-head"><div><span>DOCTRINE DEPENDENCY · ABILITY 0{abilityIndex + 1}</span><h2>{ability?.name?.toUpperCase() ?? 'ABILITY NOT ASSIGNED'}</h2></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3 CHIPS</b></div>
<button class="builder-return-link" type="button" onclick={() => active = 'specialization:focus'}>← CHANGE SPECIALIZATION + FOCUS</button>
<div class="loadout-editor-head"><div><span>ABILITY {abilityIndex + 1}</span><h2>{ability?.name?.toUpperCase() ?? 'UNASSIGNED'}</h2></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3 CHIPS</b></div>
<button class="builder-return-link" type="button" onclick={() => active = 'specialization:focus'}>← BACK TO FOCUS</button>
{#if ability}
<div class="builder-specialization-identity ability-identity"><ItemIcon src={ability.iconUrl} alt={ability.name}/><div><small>{selectedFocus.specialization.name} · {selectedFocus.name}</small><h3>{ability.name}</h3><p>{ability.description || 'No ability description has been entered.'}</p></div><b>{ability.attack ? `${ability.attack.delivery.toUpperCase()} · ${ability.attack.damage_type}` : 'ATTACK DATA INCOMPLETE'}</b></div>
<div class="builder-section-title"><div><span>ATTACHED SKILL CHIPS</span><small>CHIPS ARE COMPATIBLE WITH THIS ABILITY AND MOVE WITH ITS FOCUS</small></div><b>03 POSITIONS</b></div>
<div class="builder-specialization-identity ability-identity"><ItemIcon src={ability.iconUrl} alt="" className="ability-icon"/><div><small>{selectedFocus.specialization.name} · {selectedFocus.name}</small><h3>{ability.name}</h3><p>{ability.description || 'No ability description.'}</p></div><b>{ability.attack ? `${ability.attack.delivery.toUpperCase()} · ${ability.attack.damage_type}` : 'ATTACK DATA NEEDED'}</b></div>
<div class="builder-section-title"><div><span>SKILL CHIPS</span><small>3 SETS</small></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3</b></div>
<div class="builder-ability-chip-panels single">
<section>
<div>{#each [0,1,2] as chipSlot}{@const chipIndex = abilityIndex * 3 + chipSlot}{@const item = chipItem(chipIndex)}<button type="button" onclick={() => active = `chip:${chipIndex}`}><small>CHIP {chipSlot + 1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${draft.chips[chipIndex].quality} · ${attribute(draft.chips[chipIndex].primary.statId)?.name ?? 'NO PRIMARY'}` : 'SELECT CHIP'}</span></button>{/each}</div>
<div>{#each [0,1,2] as chipSlot}{@const chipIndex = abilityIndex * 3 + chipSlot}{@const item = chipItem(chipIndex)}<button type="button" onclick={() => active = `chip:${chipIndex}`}><ItemIcon src={item?.iconUrl} alt="" className="builder-chip-icon"/><span><small>CHIP {chipSlot + 1}</small><strong>{item?.name ?? 'EMPTY'}</strong><em>{item ? `${draft.chips[chipIndex].quality} · ${attribute(draft.chips[chipIndex].primary.statId)?.name ?? 'NO PRIMARY'}` : 'SELECT SET'}</em></span></button>{/each}</div>
</section>
</div>
{#if draft.calculationMode === 'custom'}
{@const activation = draft.skillActivations[ability.id] ?? { mode: 'off', targets: 1 }}
<div class="builder-skill-activation">
<div class="builder-section-title"><div><span>ACTIVATION PROFILE</span><small>CONTROLS THIS ABILITY'S CONTRIBUTION TO COMBINED SKILL DPS</small></div></div>
<section>
<SegmentedChoice legend={`${ability.name} activation`} name={`ability-${ability.id}-activation`} options={[{value:'off',label:'OFF'},{value:'on_cooldown',label:'ON COOLDOWN'},{value:'custom_per_minute',label:'CUSTOM / MIN'}]} value={activation.mode} onchange={(values) => setSkillActivation(ability.id,{mode:values[0]})}/>
<div class="builder-skill-assumptions">
{#if activation.mode === 'custom_per_minute'}<RangeValueControl label="Uses per minute" min={0.1} max={60} step={0.1} value={activation.usesPerMinute ?? 1} onchange={(value) => setSkillActivation(ability.id,{usesPerMinute:value})}/>{/if}
<RangeValueControl label="Targets hit" min={1} max={Math.max(1,Number(ability.attack?.target_cap ?? 1))} step={1} value={activation.targets} onchange={(value) => setSkillActivation(ability.id,{targets:value})}/>
</div>
</section>
</div>
{/if}
{:else}
<p class="error">Select a Specialization and Focus with configured abilities before attaching Skill Chips.</p>
<p class="error">Choose a focus with 3 abilities first.</p>
{/if}
{:else if activeParts[0] === 'gear'}
{@const slot = activeParts[1]}{@const selection = draft.gear[slot]}{@const item = gearItem(slot)}
<div class="loadout-editor-head"><div><span>GEAR SLOT</span><h2>{slot.toUpperCase()}</h2></div><b>{item ? `TIER ${item.tier} · BUILD LEVEL ${selection.level}` : 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.gear.filter((entry) => entry.slot === slot)} bind:value={selection.itemId} onchange={(id) => configureGear(slot,id)} label="ITEM DEFINITION"/>
<div class="loadout-editor-head"><div><span>GEAR</span><h2>{slot.toUpperCase()}</h2></div><b>{item ? `T${item.tier} · LV ${selection.level}` : 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.gear.filter((entry) => entry.slot === slot)} bind:value={selection.itemId} onchange={(id) => configureGear(slot,id)} label="GEAR ITEM"/>
{#if item}
{@const healthRange = gearBaseRange(item, 'health', selection.level)}
{@const armorRange = item.base_armor_min !== null ? gearBaseRange(item, 'armor', selection.level) : null}
<div class="builder-equipment-controls">
<RangeValueControl label="GEAR LEVEL" min={Number(item.tier) === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeGearLevel(slot,value)}/>
<BuilderRadioGrid label="BRAND SET" name={`gear-${slot}-brand`} items={item.brandIds.map((id: string) => brand(id)).filter(Boolean)} bind:value={selection.brandId} columns={Math.min(item.brandIds.length,4)} iconOnly required/>
{#if item.talentIds.length}<BuilderRadioGrid label="EQUIPPED TALENT" name={`gear-${slot}-talent`} items={item.talentIds.map((id: string) => talent(id)).filter(Boolean)} bind:value={selection.talentId} columns={Math.min(item.talentIds.length,4)} searchable={item.talentIds.length > 6} required/>{/if}
</div>
<p class:provisional={healthRange.provisional} class="builder-scaling-note">{healthRange.provisional ? 'Estimated range: levels 1–9 remain provisional while additional game samples are collected.' : 'Base Health and Armor are calculated from the stored gear base ranges at the selected item level. Display values are truncated to match the game.'}</p>
<div class="builder-range-grid"><RangeValueControl label="BASE HEALTH" min={healthRange.min} max={healthRange.max} provisional={healthRange.provisional} bind:value={selection.health}/>{#if armorRange}<RangeValueControl label="BASE ARMOR" min={armorRange.min} max={armorRange.max} provisional={armorRange.provisional} bind:value={selection.armor}/>{/if}</div>
{@const gearCore = coreAllocationRange(Number(item.tier), selection.level, 'gear')}
<div class="builder-section-title"><div><span>CORE ATTRIBUTE ALLOCATION</span><small>SHARED T{item.tier} POOL · VALUES MAY BE REDISTRIBUTED BETWEEN CORES</small></div><b class:restricted={coreTotal(selection) < gearCore.poolMin || coreTotal(selection) > gearCore.poolMax}>{coreTotal(selection)} / {gearCore.poolMin}–{gearCore.poolMax}</b></div>
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={gearCore.poolMax} bind:value={selection[key]}/>{/each}</div>
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS · VALUES ARE LIMITED TO GEAR RANGES</small></div></div>
<div class="builder-rolls required">{#each selection.rolls as roll, index}{@const rollRange = attributeRange(attribute(roll.statId), 'gear', selection.level)}<div><BuilderPicker items={gearAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'gear',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== index).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${index + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
{@const augments = data.gearAugments.filter((entry) => entry.gear_slot === slot)}
{#if augments.length}<div class="builder-section-title"><div><span>TACTICAL AUGMENTS</span><small>ENABLE COLLECTED UPGRADES · BASE BONUSES APPLY TO THIS ITEM ONLY</small></div><b>{selection.augmentSlots.length} / {augments.length} ACTIVE</b></div><div class="builder-augment-list">{#each augments as augment}<label><input type="checkbox" checked={selection.augmentSlots.includes(Number(augment.augment_slot))} onchange={() => selection.augmentSlots = selection.augmentSlots.includes(Number(augment.augment_slot)) ? selection.augmentSlots.filter((value) => value !== Number(augment.augment_slot)) : [...selection.augmentSlots,Number(augment.augment_slot)]}/><span><b>AUGMENT 0{augment.augment_slot}</b><small>{augment.effect_type === 'Custom' ? `${attribute(augment.stat_attribute_id)?.name} · ${unitValue(augment.value,augment.stat_attribute_id)}` : `+${augment.value}% BASE HEALTH${item.base_armor_min !== null ? ' + ARMOR' : ''}`}</small></span><em>{augment.costs.find((cost: any) => Number(cost.gear_tier) === Number(item.tier))?.unlock_cost ?? '—'} COST · T{item.tier}</em></label>{/each}</div>{/if}
<div class="builder-editor-columns">
<section class="builder-editor-lane" aria-label="Gear identity and base values">
<div class="builder-equipment-controls">
<RangeValueControl label="GEAR LEVEL" min={Number(item.tier) === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeGearLevel(slot,value)}/>
<BuilderRadioGrid label="BRAND SET" name={`gear-${slot}-brand`} items={item.brandIds.map((id: string) => brand(id)).filter(Boolean)} bind:value={selection.brandId} columns={Math.min(item.brandIds.length,4)} iconOnly required/>
{#if item.talentIds.length}<BuilderRadioGrid label="EQUIPPED TALENT" name={`gear-${slot}-talent`} items={item.talentIds.map((id: string) => talent(id)).filter(Boolean)} value={selection.talentId} onchange={(id) => { selection.talentId = id; selection.talentTier = supportedTalentTier(id, selection.talentTier); }} columns={Math.min(item.talentIds.length,4)} searchable={item.talentIds.length > 6} required/>{/if}
{#if selection.talentId && talent(selection.talentId)}<TalentTierChoice name={`gear-${slot}-talent-tier`} talent={talent(selection.talentId)} bind:value={selection.talentTier}/>{/if}
</div>
<p class:provisional={healthRange.provisional} class="builder-scaling-note">{healthRange.provisional ? 'Estimated range: levels 1–9 remain provisional.' : 'Base values scale with item level and use game-matching truncated display values.'}</p>
<div class="builder-range-grid"><RangeValueControl label="BASE HEALTH" min={healthRange.min} max={healthRange.max} provisional={healthRange.provisional} bind:value={selection.health}/>{#if armorRange}<RangeValueControl label="BASE ARMOR" min={armorRange.min} max={armorRange.max} provisional={armorRange.provisional} bind:value={selection.armor}/>{/if}</div>
</section>
<section class="builder-editor-lane" aria-label="Gear attributes and augments">
<div class="builder-section-title"><div><span>CORE ALLOCATION</span><small>SHARED T{item.tier} POOL</small></div><b class:restricted={coreTotal(selection) < gearCore.poolMin || coreTotal(selection) > gearCore.poolMax}>{coreTotal(selection)} / {gearCore.poolMin}–{gearCore.poolMax}</b></div>
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={gearCore.poolMax} bind:value={selection[key]}/>{/each}</div>
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS</small></div></div>
<div class="builder-rolls required">{#each selection.rolls as roll, index}{@const rollRange = attributeRange(attribute(roll.statId), 'gear', selection.level)}<div><BuilderPicker items={gearAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'gear',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== index).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${index + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
{#if augments.length}<div class="builder-section-title"><div><span>TACTICAL AUGMENTS</span><small>OPTIONAL ITEM UPGRADES</small></div><b>{selection.augmentSlots.length} / {augments.length} ACTIVE</b></div><div class="builder-augment-list">{#each augments as augment}<label><input type="checkbox" checked={selection.augmentSlots.includes(Number(augment.augment_slot))} onchange={() => selection.augmentSlots = selection.augmentSlots.includes(Number(augment.augment_slot)) ? selection.augmentSlots.filter((value) => value !== Number(augment.augment_slot)) : [...selection.augmentSlots,Number(augment.augment_slot)]}/><span><b>AUGMENT 0{augment.augment_slot}</b><small>{augment.effect_type === 'Custom' ? `${attribute(augment.stat_attribute_id)?.name} · ${unitValue(augment.value,augment.stat_attribute_id)}` : `+${augment.value}% BASE HEALTH${item.base_armor_min !== null ? ' + ARMOR' : ''}`}</small></span><em>{augment.costs.find((cost: any) => Number(cost.gear_tier) === Number(item.tier))?.unlock_cost ?? '—'} COST · T{item.tier}</em></label>{/each}</div>{/if}
</section>
</div>
{/if}
{:else if activeParts[0] === 'weapon'}
{@const index = Number(activeParts[1])}{@const selection = draft.weapons[index]}{@const item = weaponItem(index)}{@const other = weaponItem(index ? 0 : 1)}
<div class="loadout-editor-head"><div><span>WEAPON SLOT</span><h2>{index ? 'SECONDARY' : 'PRIMARY'} WEAPON</h2></div><b>{item ? item.damage_type : 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.weapons} bind:value={selection.itemId} onchange={(id) => configureWeapon(index,id)} disabledIds={other && Number(other.is_exotic) ? data.weapons.filter((entry) => Number(entry.is_exotic)).map((entry) => entry.id) : []} label="WEAPON DEFINITION"/>
<div class="loadout-editor-head"><div><span>WEAPON</span><h2>{index ? 'SECONDARY' : 'PRIMARY'}</h2></div><b>{item ? item.damage_type : 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.weapons} bind:value={selection.itemId} onchange={(id) => configureWeapon(index,id)} disabledIds={other && Number(other.is_exotic) ? data.weapons.filter((entry) => Number(entry.is_exotic)).map((entry) => entry.id) : []} label="WEAPON"/>
{#if item}
{#if Number(item.is_exotic) && other && Number(other.is_exotic)}<p class="error">Only one exotic weapon may be equipped. Choose a standard weapon in either slot.</p>{/if}
<div class="builder-weapon-controls">
<RangeValueControl label="WEAPON LEVEL" min={selection.tier === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeWeaponLevel(index,value)}/>
<SegmentedChoice legend="TIER" name={`weapon-${index}-tier`} options={item.ranges.map((range: any) => ({ value: String(range.tier), label: `T${range.tier}` }))} value={String(selection.tier)} onchange={(values) => changeWeaponTier(index,Number(values[0]))} required/>
<div class="builder-fixed"><span>{item.rpm} RPM</span><span>{item.magazine} SHOTS</span><span>{item.reload_speed}s RELOAD</span></div>
</div>
{@const range = item.ranges.find((entry: any) => Number(entry.tier) === selection.tier)}
{#if range}
<p class:provisional={selection.level < 40} class="builder-scaling-note">{selection.level < 40 ? 'Estimated range: the weapon curve below level 40 is provisional and may change as observations are added.' : 'Damage is calculated from the stored weapon base range at the selected item level. Core attributes use the shared level-50 tier allocation pool.'}</p>
{@const damageRange = weaponStatRange(range,'damage',selection.level)}
<div class="builder-range-grid weapon-damage"><RangeValueControl label="DAMAGE" min={damageRange.min} max={damageRange.max} provisional={damageRange.provisional} bind:value={selection.damage}/></div>
{@const weaponCore = coreAllocationRange(selection.tier, selection.level, 'weapon')}
<div class="builder-section-title"><div><span>CORE ATTRIBUTE ALLOCATION</span><small>SHARED T{selection.tier} POOL · VALUES MAY BE REDISTRIBUTED BETWEEN CORES</small></div><b class:restricted={coreTotal(selection) < weaponCore.poolMin || coreTotal(selection) > weaponCore.poolMax}>{coreTotal(selection)} / {weaponCore.poolMin}–{weaponCore.poolMax}</b></div>
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={weaponCore.poolMax} bind:value={selection[key]}/>{/each}</div>
{#if range}{@const damageRange = weaponStatRange(range,'damage',selection.level)}{@const weaponCore = coreAllocationRange(selection.tier, selection.level, 'weapon')}
<div class="builder-editor-columns">
<section class="builder-editor-lane" aria-label="Weapon identity and base values">
<div class="builder-weapon-controls">
<RangeValueControl label="WEAPON LEVEL" min={selection.tier === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeWeaponLevel(index,value)}/>
<SegmentedChoice legend="TIER" name={`weapon-${index}-tier`} options={item.ranges.map((entry: any) => ({ value: String(entry.tier), label: `T${entry.tier}` }))} value={String(selection.tier)} onchange={(values) => changeWeaponTier(index,Number(values[0]))} required/>
<div class="builder-fixed"><span>{item.rpm} RPM</span><span>{item.magazine} SHOTS</span><span>{item.reload_speed}s RELOAD</span></div>
</div>
<p class:provisional={selection.level < 40} class="builder-scaling-note">{selection.level < 40 ? 'Estimated range below level 40.' : 'Damage scales with the selected item level.'}</p>
<div class="builder-range-grid weapon-damage"><RangeValueControl label="DAMAGE" min={damageRange.min} max={damageRange.max} provisional={damageRange.provisional} bind:value={selection.damage}/></div>
{#if item.baseBonuses.length}<div class="builder-inherited">{#each item.baseBonuses as bonus}<span><small>{attribute(bonus.stat_attribute_id)?.name}</small><b>{unitValue(bonus.value,bonus.stat_attribute_id)}</b></span>{/each}</div>{/if}
</section>
<section class="builder-editor-lane" aria-label="Weapon attributes and talents">
<div class="builder-section-title"><div><span>CORE ALLOCATION</span><small>SHARED T{selection.tier} POOL</small></div><b class:restricted={coreTotal(selection) < weaponCore.poolMin || coreTotal(selection) > weaponCore.poolMax}>{coreTotal(selection)} / {weaponCore.poolMin}–{weaponCore.poolMax}</b></div>
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={weaponCore.poolMax} bind:value={selection[key]}/>{/each}</div>
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS</small></div></div>
<div class="builder-rolls required">{#each selection.rolls as roll, rollIndex}{@const rollRange = attributeRange(attribute(roll.statId), 'weapon', selection.level)}<div><BuilderPicker items={weaponAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'weapon',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== rollIndex).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${rollIndex + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
<div class="builder-talent-grids">
{#if item.talent1Ids.length}<section class="builder-talent-slot"><BuilderRadioGrid label="TALENT 1" name={`weapon-${index}-talent-1`} items={item.talent1Ids.map((id: string) => talent(id)).filter(Boolean)} value={selection.talent1Id} onchange={(id) => { selection.talent1Id = id; selection.talent1Tier = supportedTalentTier(id, selection.talent1Tier); }} columns={Math.min(item.talent1Ids.length,4)} searchable={item.talent1Ids.length > 6} required/>{#if selection.talent1Id && talent(selection.talent1Id)}<TalentTierChoice name={`weapon-${index}-talent-1-tier`} talent={talent(selection.talent1Id)} bind:value={selection.talent1Tier}/>{/if}</section>{/if}
{#if item.talent2Ids.length}<section class="builder-talent-slot"><BuilderRadioGrid label="TALENT 2" name={`weapon-${index}-talent-2`} items={item.talent2Ids.map((id: string) => talent(id)).filter(Boolean)} value={selection.talent2Id} onchange={(id) => { selection.talent2Id = id; selection.talent2Tier = supportedTalentTier(id, selection.talent2Tier); }} columns={Math.min(item.talent2Ids.length,4)} searchable={item.talent2Ids.length > 6} required/>{#if selection.talent2Id && talent(selection.talent2Id)}<TalentTierChoice name={`weapon-${index}-talent-2-tier`} talent={talent(selection.talent2Id)} bind:value={selection.talent2Tier}/>{/if}</section>{/if}
{#if item.exotic_talent_id && talent(item.exotic_talent_id)}<section class="builder-talent-slot builder-exotic-talent"><div><ItemIcon src={talent(item.exotic_talent_id).iconUrl} alt=""/><span><small>EXOTIC TALENT</small><b>{talent(item.exotic_talent_id).name}</b></span></div><TalentTierChoice name={`weapon-${index}-exotic-talent-tier`} talent={talent(item.exotic_talent_id)} bind:value={selection.exoticTalentTier}/></section>{/if}
</div>
</section>
</div>
{/if}
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS · VALUES ARE LIMITED TO WEAPON RANGES</small></div></div>
<div class="builder-rolls required">{#each selection.rolls as roll, rollIndex}{@const rollRange = attributeRange(attribute(roll.statId), 'weapon', selection.level)}<div><BuilderPicker items={weaponAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'weapon',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== rollIndex).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${rollIndex + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
<div class="builder-talent-grids">
{#if item.talent1Ids.length}<BuilderRadioGrid label="TALENT 1" name={`weapon-${index}-talent-1`} items={item.talent1Ids.map((id: string) => talent(id)).filter(Boolean)} bind:value={selection.talent1Id} columns={Math.min(item.talent1Ids.length,4)} searchable={item.talent1Ids.length > 6} required/>{/if}
{#if item.talent2Ids.length}<BuilderRadioGrid label="TALENT 2" name={`weapon-${index}-talent-2`} items={item.talent2Ids.map((id: string) => talent(id)).filter(Boolean)} bind:value={selection.talent2Id} columns={Math.min(item.talent2Ids.length,4)} searchable={item.talent2Ids.length > 6} required/>{/if}
</div>
{#if item.baseBonuses.length}<div class="builder-inherited">{#each item.baseBonuses as bonus}<span><small>{attribute(bonus.stat_attribute_id)?.name}</small><b>{unitValue(bonus.value,bonus.stat_attribute_id)}</b></span>{/each}</div>{/if}
{/if}
{:else if activeParts[0] === 'chip'}
{@const index = Number(activeParts[1])}{@const chip = draft.chips[index]}{@const item = chipItem(index)}{@const abilityIndex = Math.floor(index / 3)}{@const ability = selectedFocus?.abilities[abilityIndex]}
<div class="loadout-editor-head"><div><span>ABILITY {abilityIndex + 1} · CHIP {(index % 3) + 1}</span><h2>{ability?.name?.toUpperCase() ?? 'SKILL CHIP'}</h2></div><b>{chip.quality.toUpperCase()}</b></div>
<button class="builder-return-link" type="button" onclick={() => active = `ability:${abilityIndex}`}>← RETURN TO {ability?.name?.toUpperCase() ?? 'ABILITY'}</button>
{#if ability}<BuilderPicker items={data.chipSets.filter((entry: any) => entry.specialization_ability_id === ability.id)} bind:value={chip.setId} onchange={(id) => configureChip(index,id)} label="ABILITY-COMPATIBLE SKILL CHIP SET"/>{:else}<p class="error">Select a focus with three configured class abilities before equipping Skill Chips.</p>{/if}
{#if item}<div class="builder-field-grid"><label>QUALITY<select bind:value={chip.quality}><option>Superior</option><option>High-end</option></select></label></div><div class="builder-chip-rolls"><div><BuilderPicker items={chipAttributes.filter((entry) => item.primaryStatIds.includes(entry.id))} bind:value={chip.primary.statId} onchange={(id) => setRollStat(chip.primary,id,'chip')} label="PRIMARY ATTRIBUTE"/><label>VALUE<input type="number" min={attribute(chip.primary.statId)?.skill_chip_min ?? 0} max={attribute(chip.primary.statId)?.skill_chip_max ?? 0} bind:value={chip.primary.value} onblur={() => chip.primary.value = clamp(chip.primary.value,attribute(chip.primary.statId)?.skill_chip_min,attribute(chip.primary.statId)?.skill_chip_max)}/></label></div>{#each chip.secondary as roll,index}<div><BuilderPicker items={chipAttributes.filter((entry) => item.secondaryStatIds.includes(entry.id))} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'chip')} disabledIds={chip.secondary.filter((_,i) => i !== index).map((entry) => entry.statId)} label={`SECONDARY ATTRIBUTE ${index + 1}`}/><label>VALUE<input type="number" min={attribute(roll.statId)?.skill_chip_min ?? 0} max={attribute(roll.statId)?.skill_chip_max ?? 0} bind:value={roll.value} onblur={() => roll.value = clamp(roll.value,attribute(roll.statId)?.skill_chip_min,attribute(roll.statId)?.skill_chip_max)}/></label></div>{/each}</div>{/if}
<div class="loadout-editor-head"><div><span>CHIP {(index % 3) + 1} / 3</span><h2>{ability?.name?.toUpperCase() ?? 'SKILL CHIP'}</h2></div><b>{chip.quality.toUpperCase()}</b></div>
<button class="builder-return-link" type="button" onclick={() => active = `ability:${abilityIndex}`}>← BACK TO ABILITY</button>
{#if ability}<BuilderPicker items={data.chipSets.filter((entry: any) => chipSupportsAbility(entry, ability.id))} bind:value={chip.setId} onchange={(id) => configureChip(index,id)} label="CHIP SET"/>{:else}<p class="error">Choose a focus with 3 abilities first.</p>{/if}
{#if !chipProfileConfigured}<p class="error">CHIP PROFILE REQUIRED. Configure shared ranges in ISAC.</p>{/if}
{#if item}
<div class="builder-editor-columns builder-chip-editor">
<section class="builder-editor-lane" aria-label="Skill Chip quality and primary Attribute">
<div class="builder-field-grid"><SegmentedChoice legend="QUALITY" name={`chip-${index}-quality`} options={skillChipQualities.map((quality) => ({ value: quality, label: quality.toUpperCase() }))} value={chip.quality} onchange={(values) => changeChipQuality(index, values[0] as SkillChipQuality)} required/></div>
<div class="builder-section-title"><div><span>PRIMARY</span><small>ABILITY ONLY</small></div><b>NOT IN TOTALS</b></div>
<p class="builder-scaling-note">Not included in totals.</p>
<div class="builder-chip-rolls"><div><BuilderPicker items={chipPrimaryAttributes} bind:value={chip.primary.statId} onchange={(id) => setChipRoll(chip.primary,id,'primary',chip.quality)} label="PRIMARY"/>{#if validChipRange(chipRange('primary', chip.primary.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('primary', chip.primary.statId, chip.quality)} max={chipRangeMax('primary', chip.primary.statId, chip.quality)} step={0.01} bind:value={chip.primary.value}/>{:else}<p class="error">PRIMARY RANGE REQUIRED</p>{/if}</div></div>
</section>
<section class="builder-editor-lane" aria-label="Skill Chip secondary Attributes">
<div class="builder-section-title"><div><span>SECONDARIES</span><small>{skillChipSecondaryCapacity[chip.quality]} SLOT{skillChipSecondaryCapacity[chip.quality] === 1 ? '' : 'S'}</small></div></div>
<div class="builder-chip-rolls">{#each chip.secondary as roll, secondaryIndex}<div><BuilderPicker items={chipSecondaryAttributes} bind:value={roll.statId} onchange={(id) => setChipRoll(roll,id,'secondary',chip.quality)} disabledIds={chip.secondary.filter((_,i) => i !== secondaryIndex).map((entry) => entry.statId).filter(Boolean)} label={`SECONDARY ${secondaryIndex + 1}`}/>{#if validChipRange(chipRange('secondary', roll.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('secondary', roll.statId, chip.quality)} max={chipRangeMax('secondary', roll.statId, chip.quality)} step={0.01} bind:value={roll.value}/>{:else}<p class="error">SECONDARY RANGE REQUIRED</p>{/if}</div>{/each}</div>
{#if skillChipSecondaryCapacity[chip.quality] === 0}<p class="builder-scaling-note">NO SECONDARIES</p>{/if}
</section>
</div>
{/if}
{:else}
{@const protocol = protocolItem()}
<div class="loadout-editor-head"><div><span>AGENT SYSTEM</span><h2>OS PROTOCOL</h2></div><b>{protocol?.quality?.toUpperCase() ?? 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.protocols} bind:value={draft.protocol.itemId} label="OS PROTOCOL"/>
<div class="loadout-editor-head"><div><span>PROTOCOL</span><h2>OS PROTOCOL</h2></div><b>{protocol?.quality?.toUpperCase() ?? 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.protocols} bind:value={draft.protocol.itemId} label="PROTOCOL"/>
{#if protocol}<div class="builder-range-grid"><label>PROTOCOL LEVEL <span>01–50</span><input type="number" min="1" max="50" bind:value={draft.protocol.level} onblur={() => draft.protocol.level = clamp(draft.protocol.level,1,50)}/></label><label>TALENT UPGRADES <span>0–5</span><input type="number" min="0" max="5" bind:value={draft.protocol.talentUpgrades} onblur={() => draft.protocol.talentUpgrades = clamp(draft.protocol.talentUpgrades,0,5)}/></label></div><div class="builder-inherited">{#if protocol.primaryEffect}<span><small>{attribute(protocol.primaryEffect.stat_attribute_id)?.name} · LEVEL {draft.protocol.level}</small><b>{unitValue(Number(protocol.primaryEffect.base_value)*(1+(draft.protocol.level-1)/49),protocol.primaryEffect.stat_attribute_id)}</b></span>{/if}{#each protocol.secondaryEffects as effect}<span><small>{attribute(effect.stat_attribute_id)?.name}</small><b>{unitValue(effect.value,effect.stat_attribute_id)}</b></span>{/each}</div>{/if}
{/if}
</section>
<aside class="loadout-summary">
<div class="summary-head"><div><p class="eyebrow"><span></span> LIVE SUMMARY</p><h2>AGENT STATS</h2></div><b>{summary.stats.length}</b></div>
<div class="summary-core">{#each ['Firepower','Toughness','Engineering'] as name}{@const stat = summary.stats.find((entry) => attribute(entry.id)?.name === name)}<div><GameIcon name={name.toLowerCase() as GameIconName} decorative/><span>{name}<b>{stat ? unitValue(stat.value,stat.id) : '0'}</b></span></div>{/each}</div>
<div class="builder-combat-results">{#each combatResults as result}<WeaponCalculationResult {result} resolverHint="Use the warning markers in the loadout matrix."/>{/each}</div>
<div class="builder-combat-results"><SkillCalculationResult result={skillCombatResult} abilityNames={skillAbilityNames} resolverHint="Open the marked doctrine or ability cell."/></div>
<div class="summary-scroll">
<div class="summary-section"><header><span>COMBINED ATTRIBUTES</span><b>{summary.stats.length}</b></header>{#each summary.stats as stat}<div class="summary-stat"><span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span><b>{unitValue(stat.value,stat.id)}</b></div>{:else}<p>Equip and configure items to calculate attributes.</p>{/each}</div>
<div class="summary-section"><header><span>ACTIVE SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>{#each summary.activeBonuses as bonus}<div class="summary-bonus"><span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span><em>+{unitValue(bonus.value,bonus.statId)} {attribute(bonus.statId)?.name}</em></div>{:else}<p>Matching Brand Sets and Skill Chips activate bonuses here.</p>{/each}</div>
<div class="summary-section"><header><span>EQUIPPED TALENTS</span><b>{summary.equippedTalents.length}</b></header>{#each summary.equippedTalents as equipped}<label class="summary-talent">{#if draft.calculationMode === 'custom'}<input type="checkbox" checked={(draft.conditionalEffects[equipped.key]?.mode ?? 'off') !== 'off'} onchange={() => toggleTalent(equipped.key)}/>{/if}<span><b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b><small>{equipped.source} · {draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : (draft.conditionalEffects[equipped.key]?.mode ?? 'off') !== 'off' ? 'INCLUDED' : 'CONDITIONAL'}</small><em>{equipped.description}</em>{#if equipped.id && talentCooldown(equipped.id)}<i class="talent-cooldown">{talentCooldown(equipped.id)}</i>{/if}</span></label>{:else}<p>Selected equipment talents appear here.</p>{/each}</div>
</div>
<p class="notice">{draft.calculationMode === 'maximum' ? 'Maximum Potential applies every configured effect at best-case uptime. ' : 'Custom Scenario uses each mechanic program and the encounter settings saved with this loadout. '}Anonymous drafts remain on this device.</p>
<LoadoutStatsSummary {summary} attributes={data.attributes} talents={data.talents} headingId="builder-known-stats-heading" />
<WeaponBaselineThroughput entries={weaponBaselines} headingId="builder-weapon-output-heading" />
<div class="builder-planned-model"><PlannedCombatModel headingId="builder-planned-output-heading" variant="compact" /></div>
<p class="notice">Anonymous drafts remain on this device. Account saves preserve the equipped-item configuration and current visibility.</p>
</aside>
</div>
</main>
+290
View File
@@ -0,0 +1,290 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createDraft, type LoadoutDraft } from '$lib/builder/loadout';
const mocks = vi.hoisted(() => ({
insertBuild: vi.fn(),
getSessionUser: vi.fn(async () => ({
id: 'agent-1',
displayName: 'Test Agent',
})),
database: {
prepare: vi.fn(),
},
}));
vi.mock('$lib/server/auth/session', () => ({
getSessionUser: mocks.getSessionUser,
}));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => mocks.database),
}));
import { actions } from './+page.server';
const primaryStatId = 'skill-duration';
const secondaryStatIds = ['critical-hit-chance', 'critical-hit-damage'];
const validLoadout = (): LoadoutDraft => {
const loadout = createDraft();
loadout.buildName = 'Validated Skill Chips';
loadout.specialization.focusId = 'focus-1';
loadout.chips[0] = {
setId: 'chip-set-1',
quality: 'Superior',
primary: { statId: primaryStatId, value: 15 },
secondary: [
{ statId: secondaryStatIds[0], value: 2 },
{ statId: secondaryStatIds[1], value: 4 },
],
};
return loadout;
};
const saveRequest = (loadout: LoadoutDraft) => {
const form = new FormData();
form.set('loadout', JSON.stringify(loadout));
form.set('visibility', 'private');
return new Request('http://localhost/builder?/save', {
method: 'POST',
body: form,
});
};
const runSave = (loadout: LoadoutDraft) =>
actions.save?.({
cookies: {} as never,
request: saveRequest(loadout),
} as never);
describe('Builder Skill Chip save validation', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.database.prepare.mockImplementation(async (sql: string) => ({
all: vi.fn(async () => {
if (sql.includes('FROM skill_chip_sets'))
return [
{
id: 'chip-set-1',
specialization_ability_id: 'ability-1',
},
];
if (sql.includes('FROM specialization_focus_abilities'))
return [{ ability_id: 'ability-1', ability_slot: 1 }];
if (sql.includes('FROM skill_chip_primary_options'))
return [{ stat_attribute_id: primaryStatId }];
if (sql.includes('FROM skill_chip_primary_quality_values'))
return [
{
stat_attribute_id: primaryStatId,
quality: 'Superior',
min_value: 10,
max_value: 20,
},
];
if (sql.includes('FROM skill_chip_secondary_options'))
return secondaryStatIds.map((stat_attribute_id) => ({
stat_attribute_id,
}));
if (sql.includes('FROM skill_chip_secondary_quality_values'))
return secondaryStatIds.map((stat_attribute_id) => ({
stat_attribute_id,
quality: 'Superior',
min_value: 1,
max_value: 5,
}));
return [];
}),
run: mocks.insertBuild,
}));
});
it('rejects a selected Skill Chip assigned to a different focus ability', async () => {
mocks.database.prepare.mockImplementation(async (sql: string) => ({
all: vi.fn(async () => {
if (sql.includes('FROM skill_chip_sets'))
return [
{
id: 'chip-set-1',
specialization_ability_id: 'ability-2',
},
];
if (sql.includes('FROM specialization_focus_abilities'))
return [{ ability_id: 'ability-1', ability_slot: 1 }];
return [];
}),
run: mocks.insertBuild,
}));
const result = await runSave(validLoadout());
expect(result).toMatchObject({
status: 400,
data: {
buildError: 'Skill Chip 1 is not compatible with its assigned ability.',
},
});
expect(mocks.insertBuild).not.toHaveBeenCalled();
});
it('accepts a Skill Chip owned by an explicitly compatible ability', async () => {
mocks.database.prepare.mockImplementation(async (sql: string) => ({
all: vi.fn(async () => {
if (sql.includes('FROM skill_chip_sets'))
return [
{
id: 'chip-set-1',
specialization_ability_id: 'ability-2',
},
];
if (sql.includes('FROM specialization_ability_skill_chip_links'))
return [
{
ability_id: 'ability-1',
compatible_ability_id: 'ability-2',
},
];
if (sql.includes('FROM specialization_focus_abilities'))
return [{ ability_id: 'ability-1', ability_slot: 1 }];
if (sql.includes('FROM skill_chip_primary_options'))
return [{ stat_attribute_id: primaryStatId }];
if (sql.includes('FROM skill_chip_primary_quality_values'))
return [
{
stat_attribute_id: primaryStatId,
quality: 'Superior',
min_value: 10,
max_value: 20,
},
];
if (sql.includes('FROM skill_chip_secondary_options'))
return secondaryStatIds.map((stat_attribute_id) => ({
stat_attribute_id,
}));
if (sql.includes('FROM skill_chip_secondary_quality_values'))
return secondaryStatIds.map((stat_attribute_id) => ({
stat_attribute_id,
quality: 'Superior',
min_value: 1,
max_value: 5,
}));
return [];
}),
run: mocks.insertBuild,
}));
const result = await runSave(validLoadout());
expect(result).toMatchObject({ buildSaved: true });
expect(mocks.insertBuild).toHaveBeenCalledOnce();
});
it.each([
{
label: 'primary',
mutate: (loadout: LoadoutDraft) => {
loadout.chips[0].primary.value = 21;
},
message: 'Skill Chip 1 has an invalid primary Attribute value.',
},
{
label: 'secondary',
mutate: (loadout: LoadoutDraft) => {
loadout.chips[0].secondary[1].value = 6;
},
message: 'Skill Chip 1 has an invalid secondary Attribute value.',
},
])('rejects an out-of-range $label value', async ({ mutate, message }) => {
const loadout = validLoadout();
mutate(loadout);
const result = await runSave(loadout);
expect(result).toMatchObject({
status: 400,
data: { buildError: message },
});
expect(mocks.insertBuild).not.toHaveBeenCalled();
});
it('rejects duplicate secondary Attributes', async () => {
const loadout = validLoadout();
loadout.chips[0].secondary[1].statId = secondaryStatIds[0];
const result = await runSave(loadout);
expect(result).toMatchObject({
status: 400,
data: {
buildError: 'Skill Chip 1 must use distinct secondary Attributes.',
},
});
expect(mocks.insertBuild).not.toHaveBeenCalled();
});
it('accepts primary and secondary values within the configured profiles', async () => {
const result = await runSave(validLoadout());
expect(result).toMatchObject({
buildSaved: true,
buildToken: expect.any(String),
savedBuild: {
visibility: 'private',
title: 'Validated Skill Chips',
},
});
expect(mocks.insertBuild).toHaveBeenCalledOnce();
});
it('rejects an equipment Talent tier without canonical tier data', async () => {
const loadout = createDraft();
loadout.buildName = 'Invalid Talent Tier';
loadout.gear.Holster.itemId = 'gear-1';
loadout.gear.Holster.talentId = 'talent-1';
loadout.gear.Holster.talentTier = 2;
mocks.database.prepare.mockImplementation(async (sql: string) => ({
all: vi.fn(async () => {
if (sql.includes('FROM gear_talents'))
return [{ gear_id: 'gear-1', talent_id: 'talent-1' }];
if (sql.includes('FROM talent_tiers'))
return [{ talent_id: 'talent-1', tier: 'I' }];
return [];
}),
run: mocks.insertBuild,
}));
const result = await runSave(loadout);
expect(result).toMatchObject({
status: 400,
data: { buildError: 'Holster Talent T2 is not configured.' },
});
expect(mocks.insertBuild).not.toHaveBeenCalled();
});
it('saves an equipment Talent with an authored Tier II', async () => {
const loadout = createDraft();
loadout.buildName = 'Tier Two Talent';
loadout.gear.Holster.itemId = 'gear-1';
loadout.gear.Holster.talentId = 'talent-1';
loadout.gear.Holster.talentTier = 2;
mocks.database.prepare.mockImplementation(async (sql: string) => ({
all: vi.fn(async () => {
if (sql.includes('FROM gear_talents'))
return [{ gear_id: 'gear-1', talent_id: 'talent-1' }];
if (sql.includes('FROM talent_tiers'))
return [
{ talent_id: 'talent-1', tier: 'I' },
{ talent_id: 'talent-1', tier: 'II' },
];
return [];
}),
run: mocks.insertBuild,
}));
const result = await runSave(loadout);
expect(result).toMatchObject({ buildSaved: true });
expect(mocks.insertBuild).toHaveBeenCalledOnce();
});
});
+9 -84
View File
@@ -1,90 +1,15 @@
import { error } from '@sveltejs/kit';
import { getDatabase } from '$lib/server/database';
import { getSessionUser } from '$lib/server/auth/session';
import { parseLoadoutDraft, type LoadoutDraft } from '$lib/builder/loadout';
import {
evaluateLoadoutCombat,
summarizeLoadout,
} from '$lib/builder/loadout-combat';
import { loadSharedBuildCombatCatalog } from '$lib/server/shared-build-combat';
import { avatarUrl } from '$lib/avatar';
import { publicOrigin } from '$lib/server/public-origin';
import { loadSharedBuildProjection } from '$lib/server/shared-build';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, cookies }) => {
const database = await getDatabase();
export const load: PageServerLoad = async ({ params, cookies, url }) => {
const viewer = await getSessionUser(cookies);
const build = await (
await database.prepare(`
SELECT builds.*,COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author,
user_profiles.public_slug,
COALESCE(user_profiles.is_public,1) AS profile_is_public,deleted_accounts.user_id AS deleted_user_id,
user_avatars.source AS avatar_source,user_avatars.value AS avatar_value,
user_avatars.updated_at AS avatar_updated_at
FROM builds
JOIN users ON users.id=builds.owner_id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
LEFT JOIN user_avatars ON user_avatars.user_id=users.id
WHERE builds.slug=?
`)
).get(params.slug);
if (
!build ||
(build.visibility === 'private' && build.owner_id !== viewer?.id)
)
error(404, 'Build not found');
let loadout: LoadoutDraft;
try {
const parsed = parseLoadoutDraft(JSON.parse(String(build.loadout_json)));
if (!parsed) error(404, 'Build data is unavailable');
loadout = parsed;
} catch {
error(404, 'Build data is unavailable');
}
const [gear, weapons, chips, protocols, brands, combatCatalog] =
await Promise.all([
(await database.prepare('SELECT id,name,slot,tier FROM gear')).all(),
(
await database.prepare('SELECT id,name,category,is_exotic FROM weapons')
).all(),
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
(
await database.prepare('SELECT id,name,quality FROM os_protocols')
).all(),
(await database.prepare('SELECT id,name FROM brand_sets')).all(),
loadSharedBuildCombatCatalog(database),
]);
const combatResults = evaluateLoadoutCombat(
loadout,
combatCatalog,
summarizeLoadout(loadout, combatCatalog),
);
return {
build: {
id: String(build.id),
title: String(build.title),
slug: String(build.slug),
visibility: String(build.visibility),
author: String(build.author),
ownerId: String(build.owner_id),
authorPublicSlug: build.public_slug ? String(build.public_slug) : null,
profileAvailable:
Boolean(build.profile_is_public) && !build.deleted_user_id,
authorAvatarUrl: avatarUrl(
build.avatar_source,
build.avatar_value,
String(build.owner_id),
build.avatar_updated_at,
),
owner: build.owner_id === viewer?.id,
updatedAt: String(build.updated_at),
},
loadout,
gear,
weapons,
chips,
protocols,
brands,
combatResults,
};
const projection = await loadSharedBuildProjection(params.slug, {
kind: 'viewer-page',
viewerId: viewer?.id ?? null,
});
if (!projection) error(404, 'Build not found');
return { ...projection, publicOrigin: publicOrigin(url.origin) };
};
+43 -17
View File
@@ -1,45 +1,71 @@
<script lang="ts">
import { gearSlots } from '$lib/builder/loadout';
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import WeaponCalculationResult from '$lib/components/builder/WeaponCalculationResult.svelte';
import BuildShareControl from '$lib/components/BuildShareControl.svelte';
import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte';
import PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte';
let { data } = $props();
const gear = (id: string) => data.gear.find((item) => item.id === id);
const weapon = (id: string) => data.weapons.find((item) => item.id === id);
const chip = (id: string) => data.chips.find((item) => item.id === id);
const protocol = (id: string) => data.protocols.find((item) => item.id === id);
const brand = (id: string) => data.brands.find((item) => item.id === id);
const talent = (id: string) => data.talents.find((item) => item.id === id);
const attribute = (id: string) => data.attributes.find((item) => item.id === id);
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
const rollLine = (rolls: Array<{ statId: string; value: number }>) => rolls
.filter((roll) => roll.statId)
.map((roll) => `${attribute(roll.statId)?.name ?? 'Attribute'} ${number.format(roll.value)}${attribute(roll.statId)?.unit ?? ''}`)
.join(' · ');
const coreLine = (selection: { firepower: number; toughness: number; engineering: number }) =>
`FP ${number.format(selection.firepower)} · TGH ${number.format(selection.toughness)} · ENG ${number.format(selection.engineering)}`;
const description = $derived(`${data.build.title}, a Division Resurgence Build by ${data.build.author}. View its equipped weapons, gear, Skill Chips, and known Agent stats on SHD.build.`);
const canonicalUrl = $derived(new URL(`/builds/${encodeURIComponent(data.build.slug)}`, data.publicOrigin).toString());
const previewUrl = $derived(new URL(`${canonicalUrl}/preview.png`).toString());
</script>
<svelte:head><title>{data.build.title} — SHD.build</title></svelte:head>
<svelte:head>
<title>{data.build.title} — SHD.build</title>
<meta name="description" content={description}/>
<link rel="canonical" href={canonicalUrl}/>
<meta property="og:type" content="website"/>
<meta property="og:site_name" content="SHD.build"/>
<meta property="og:title" content={`${data.build.title} — SHD.build`}/>
<meta property="og:description" content={description}/>
<meta property="og:url" content={canonicalUrl}/>
{#if data.build.visibility !== 'private'}
<meta property="og:image" content={previewUrl}/>
<meta property="og:image:width" content="1200"/>
<meta property="og:image:height" content="630"/>
<meta property="og:image:type" content="image/png"/>
<meta property="og:image:alt" content={`Build preview for ${data.build.title}`}/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:image" content={previewUrl}/>
{/if}
{#if data.build.visibility !== 'public'}<meta name="robots" content="noindex,nofollow"/>{/if}
</svelte:head>
<main class="app-page shared-build-page">
<header class="shared-build-command">
<div><p class="eyebrow"><span></span> SHD LOADOUT ARCHIVE / {data.build.visibility.toUpperCase()}</p><h1>{data.build.title}</h1><AgentIdentityLink size="compact" linked={data.build.profileAvailable} agent={{ id: data.build.ownerId, displayName: data.build.author, publicSlug: data.build.authorPublicSlug, avatarUrl: data.build.authorAvatarUrl }} meta={`UPDATED ${new Date(data.build.updatedAt).toLocaleDateString()}`}/></div>
<div>{#if data.build.owner}<a class="secondary" href={`/guides/new?build=${encodeURIComponent(data.build.id)}`}>CREATE GUIDE FOR BUILD</a><a class="secondary" href={`/builder?build=${data.build.slug}`}>EDIT LOADOUT</a>{/if}<a class="primary" href="/builder">NEW BUILD +</a></div>
</header>
<section class="builder-combat-results" aria-labelledby="shared-combat-heading">
<div class="builder-section-title">
<div>
<span>LIVE COMBAT MODEL</span>
<h2 id="shared-combat-heading">CURRENT WEAPON OUTPUT</h2>
</div>
<b>CURRENT ISAC RULES</b>
</div>
{#each data.combatResults as result}
<WeaponCalculationResult {result}/>
{/each}
</section>
<BuildShareControl title={data.build.title} slugOrUrl={canonicalUrl} visibility={data.build.visibility}/>
<div class="shared-build-intelligence">
<LoadoutStatsSummary summary={data.summary} attributes={data.attributes} talents={data.talents} headingId="shared-known-stats-heading" layout="page" />
<PlannedCombatModel headingId="shared-planned-model-heading" headingLevel="h2" variant="compact" />
</div>
<div class="shared-build-grid">
<section>
<header><span>WEAPONS</span><b>02 SLOTS</b></header>
{#each data.loadout.weapons as selection, index}{@const item=weapon(selection.itemId)}<article><small>{index ? 'SECONDARY' : 'PRIMARY'}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${item.category} · T${selection.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span></article>{/each}
{#each data.loadout.weapons as selection, index}{@const item=weapon(selection.itemId)}<article><small>{index ? 'SECONDARY' : 'PRIMARY'}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${item.category} · ${item.damage_type} · T${selection.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span>{#if item}<div class="shared-item-facts"><b>{number.format(selection.damage)} DAMAGE</b><b>{item.rpm} RPM</b><b>{item.magazine} ROUNDS</b><b>{item.reload_speed}s RELOAD</b>{#if selection.talent1Id}<b>{talent(selection.talent1Id)?.name ?? 'TALENT 1'} · T{selection.talent1Tier}</b>{/if}{#if selection.talent2Id}<b>{talent(selection.talent2Id)?.name ?? 'TALENT 2'} · T{selection.talent2Tier}</b>{/if}{#if item.exotic_talent_id}<b>{talent(item.exotic_talent_id)?.name ?? 'EXOTIC TALENT'} · T{selection.exoticTalentTier}</b>{/if}</div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</article>{/each}
</section>
<section>
<header><span>GEAR</span><b>06 SLOTS</b></header>
{#each gearSlots as slot}{@const selection=data.loadout.gear[slot]}{@const item=gear(selection.itemId)}<article><small>{slot.toUpperCase()}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${brand(selection.brandId)?.name ?? 'NO BRAND'} · T${item.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span></article>{/each}
{#each gearSlots as slot}{@const selection=data.loadout.gear[slot]}{@const item=gear(selection.itemId)}<article><small>{slot.toUpperCase()}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${brand(selection.brandId)?.name ?? 'NO BRAND'} · T${item.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span>{#if item}<div class="shared-item-facts"><b>{number.format(selection.health)} HEALTH</b>{#if item.base_armor_min !== null}<b>{number.format(selection.armor)} ARMOR</b>{/if}{#if selection.talentId}<b>{talent(selection.talentId)?.name ?? 'TALENT'} · T{selection.talentTier}</b>{/if}</div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</article>{/each}
</section>
<section>
<header><span>AGENT SYSTEMS</span><b>10 SLOTS</b></header>
{#each data.loadout.chips as selection, index}{@const item=chip(selection.setId)}<article><small>SKILL CHIP 0{index+1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? selection.quality : 'NOT CONFIGURED'}</span></article>{/each}
{#each data.loadout.chips as selection, index}{@const item=chip(selection.setId)}<article><small>SKILL CHIP 0{index+1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? selection.quality : 'NOT CONFIGURED'}</span>{#if item}<em>ABILITY-SCOPED PRIMARY · {rollLine([selection.primary]) || 'NOT CONFIGURED'} · NOT YET DEFINED{selection.secondary.length ? ` · SECONDARY · ${rollLine(selection.secondary)}` : ''}</em>{/if}</article>{/each}
{#if protocol(data.loadout.protocol.itemId)}{@const protocolItem=protocol(data.loadout.protocol.itemId)}<article><small>OS PROTOCOL</small><strong>{protocolItem.name}</strong><span>LEVEL {data.loadout.protocol.level} · {data.loadout.protocol.talentUpgrades}/5 UPGRADES</span></article>{:else}<article><small>OS PROTOCOL</small><strong>EMPTY SLOT</strong><span>NOT CONFIGURED</span></article>{/if}
</section>
</div>
@@ -0,0 +1,180 @@
import { createHash } from 'node:crypto';
import { error } from '@sveltejs/kit';
import {
renderBuildSocialCardPng,
type BuildSocialCardFact,
type BuildSocialCardInput,
} from '$lib/server/build-social-card';
import { publicOrigin } from '$lib/server/public-origin';
import { loadSharedBuildProjection } from '$lib/server/shared-build';
import type { RequestHandler } from './$types';
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
const MAX_CACHE_BYTES = 16 * 1024 * 1024;
const MAX_ACTIVE_REQUESTS = 4;
const MAX_QUEUED_REQUESTS = 32;
type CachedImage = { body: Buffer; etag: string };
const imageCache = new Map<string, CachedImage>();
const pendingImages = new Map<string, Promise<CachedImage>>();
const requestQueue: Array<(release: () => void) => void> = [];
let cacheBytes = 0;
let activeRequests = 0;
function unitValue(value: number, unit: string | null | undefined) {
const rendered = number.format(value);
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit ?? ''}`;
}
export function _socialCardInput(
projection: NonNullable<
Awaited<ReturnType<typeof loadSharedBuildProjection>>
>,
directUrl: string,
): BuildSocialCardInput {
const attribute = (id: string) =>
projection.attributes.find((item) => String(item.id) === id);
const statByName = (name: string) =>
projection.summary.stats.find(
(stat) => String(attribute(stat.id)?.name) === name,
);
const statValue = (name: string) => {
const stat = statByName(name);
return stat ? unitValue(Number(stat.value), attribute(stat.id)?.unit) : '0';
};
const weaponName = (index: number) => {
const selected = projection.loadout.weapons[index];
return (
projection.weapons.find((item) => String(item.id) === selected?.itemId)
?.name ?? 'Unassigned'
);
};
const coreNames = new Set(['Firepower', 'Toughness', 'Engineering']);
const supportingAttributes: BuildSocialCardFact[] = projection.summary.stats
.filter((stat) => !coreNames.has(String(attribute(stat.id)?.name)))
.slice(0, 3)
.map((stat) => ({
label: String(attribute(stat.id)?.name ?? 'Attribute'),
value: unitValue(Number(stat.value), attribute(stat.id)?.unit),
}));
return {
title: projection.build.title,
author: projection.build.author,
primaryWeapon: String(weaponName(0)),
secondaryWeapon: String(weaponName(1)),
firepower: statValue('Firepower'),
toughness: statValue('Toughness'),
engineering: statValue('Engineering'),
supportingAttributes,
visibility: projection.build.visibility as 'public' | 'unlisted',
directUrl,
};
}
function matchesEtag(request: Request, etag: string) {
return request.headers
.get('if-none-match')
?.split(',')
.some((value) => value.trim() === etag);
}
function releaseRequest() {
const next = requestQueue.shift();
if (next) {
next(releaseRequest);
return;
}
activeRequests -= 1;
}
function acquireRequest(): Promise<(() => void) | null> {
if (activeRequests < MAX_ACTIVE_REQUESTS) {
activeRequests += 1;
return Promise.resolve(releaseRequest);
}
if (requestQueue.length >= MAX_QUEUED_REQUESTS) return Promise.resolve(null);
return new Promise((resolve) => requestQueue.push(resolve));
}
function storeImage(key: string, image: CachedImage) {
imageCache.set(key, image);
cacheBytes += image.body.byteLength;
while (cacheBytes > MAX_CACHE_BYTES && imageCache.size > 1) {
const oldestKey = imageCache.keys().next().value;
if (!oldestKey) break;
const oldest = imageCache.get(oldestKey);
imageCache.delete(oldestKey);
cacheBytes -= oldest?.body.byteLength ?? 0;
}
}
async function loadImage(input: BuildSocialCardInput, inputKey: string) {
const cached = imageCache.get(inputKey);
if (cached) {
imageCache.delete(inputKey);
imageCache.set(inputKey, cached);
return cached;
}
const pending = pendingImages.get(inputKey);
if (pending) return pending;
const request = renderBuildSocialCardPng(input).then((body) => {
const image = {
body,
etag: `"${createHash('sha256').update(body).digest('base64url')}"`,
};
storeImage(inputKey, image);
return image;
});
pendingImages.set(inputKey, request);
try {
return await request;
} finally {
pendingImages.delete(inputKey);
}
}
export const GET: RequestHandler = async ({ params, request, url }) => {
const release = await acquireRequest();
if (!release) error(503, 'Build preview capacity exceeded');
try {
const projection = await loadSharedBuildProjection(params.slug, {
kind: 'public-preview',
});
if (!projection) error(404, 'Build preview not found');
const origin = publicOrigin(url.origin);
const directUrl = new URL(
`/builds/${encodeURIComponent(projection.build.slug)}`,
origin,
).toString();
const input = _socialCardInput(projection, directUrl);
const inputKey = createHash('sha256')
.update(JSON.stringify({ version: 1, input }))
.digest('base64url');
const image = await loadImage(input, inputKey);
const cacheControl = 'public, max-age=0, must-revalidate';
if (matchesEtag(request, image.etag)) {
return new Response(null, {
status: 304,
headers: { 'cache-control': cacheControl, etag: image.etag },
});
}
return new Response(new Uint8Array(image.body), {
headers: {
'cache-control': cacheControl,
'content-length': String(image.body.byteLength),
'content-type': 'image/png',
'cross-origin-resource-policy': 'same-origin',
etag: image.etag,
'x-content-type-options': 'nosniff',
...(projection.build.visibility === 'unlisted'
? { 'x-robots-tag': 'noindex' }
: {}),
},
});
} finally {
release();
}
};
@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
loadProjection: vi.fn(),
render: vi.fn(),
}));
vi.mock('$lib/server/shared-build', () => ({
loadSharedBuildProjection: mocks.loadProjection,
}));
vi.mock('$lib/server/build-social-card', () => ({
renderBuildSocialCardPng: mocks.render,
}));
import { GET, _socialCardInput } from './+server';
const projection = {
build: {
title: 'Field Medic',
slug: 'field-medic-12345678',
author: 'Aewyn',
visibility: 'unlisted',
},
loadout: {
weapons: [{ itemId: 'weapon-1' }, { itemId: 'weapon-2' }],
},
weapons: [
{ id: 'weapon-1', name: 'ACR' },
{ id: 'weapon-2', name: 'Super 90' },
],
attributes: [
{ id: 'fp', name: 'Firepower', unit: '' },
{ id: 'tgh', name: 'Toughness', unit: '' },
{ id: 'eng', name: 'Engineering', unit: '' },
{ id: 'crit', name: 'Critical Hit Chance', unit: '%' },
],
summary: {
stats: [
{ id: 'fp', value: 1200 },
{ id: 'tgh', value: 950 },
{ id: 'eng', value: 610 },
{ id: 'crit', value: 18.5 },
],
},
};
describe('Build social preview endpoint', () => {
beforeEach(() => {
mocks.loadProjection.mockReset().mockResolvedValue(projection);
mocks.render.mockReset().mockResolvedValue(Buffer.from([1, 2, 3]));
});
it('projects canonical Build facts into the card input', () => {
expect(
_socialCardInput(
projection as never,
'https://shd.build/builds/field-medic-12345678',
),
).toEqual({
title: 'Field Medic',
author: 'Aewyn',
primaryWeapon: 'ACR',
secondaryWeapon: 'Super 90',
firepower: '1,200',
toughness: '950',
engineering: '610',
supportingAttributes: [{ label: 'Critical Hit Chance', value: '18.5%' }],
visibility: 'unlisted',
directUrl: 'https://shd.build/builds/field-medic-12345678',
});
});
it('serves a cacheable PNG and honors its ETag', async () => {
const first = await GET({
params: { slug: 'field-medic-12345678' },
request: new Request(
'https://shd.build/builds/field-medic-12345678/preview.png',
),
url: new URL('https://shd.build/builds/field-medic-12345678/preview.png'),
} as never);
expect(first.status).toBe(200);
expect(first.headers.get('content-type')).toBe('image/png');
expect(first.headers.get('x-content-type-options')).toBe('nosniff');
expect(first.headers.get('cache-control')).toBe(
'public, max-age=0, must-revalidate',
);
expect(first.headers.get('x-robots-tag')).toBe('noindex');
expect(mocks.loadProjection).toHaveBeenCalledWith('field-medic-12345678', {
kind: 'public-preview',
});
const etag = first.headers.get('etag');
expect(etag).toBeTruthy();
const second = await GET({
params: { slug: 'field-medic-12345678' },
request: new Request(
'https://shd.build/builds/field-medic-12345678/preview.png',
{ headers: { 'if-none-match': String(etag) } },
),
url: new URL('https://shd.build/builds/field-medic-12345678/preview.png'),
} as never);
expect(second.status).toBe(304);
expect(mocks.render).toHaveBeenCalledTimes(1);
});
it('returns a generic 404 when the public projection is unavailable', async () => {
mocks.loadProjection.mockResolvedValue(null);
await expect(
GET({
params: { slug: 'private-build' },
request: new Request(
'https://shd.build/builds/private-build/preview.png',
),
url: new URL('https://shd.build/builds/private-build/preview.png'),
} as never),
).rejects.toMatchObject({ status: 404 });
expect(mocks.render).not.toHaveBeenCalled();
});
});
+94 -34
View File
@@ -40,6 +40,10 @@ import { recordIsacAudit } from '$lib/server/audit';
import { removePublicComment } from '$lib/server/comment-removal';
import { createNotification } from '$lib/server/notifications';
import type { Actions } from './$types';
import {
publicWeaponModSlots,
weaponModSlotDefinitionByName,
} from '$lib/weapons/mod-slots';
const number = (value: unknown) => {
const numeric = Number(value);
@@ -336,8 +340,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
rowsSql = `SELECT item.*,render.id AS render_id,render.updated_at AS render_updated_at,
native.damage_min,native.damage_max,native.reference_level,
(SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=1) AS talent_1_count,
(SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=2) AS talent_2_count,
(SELECT COUNT(*) FROM weapon_category_attribute_bonuses bonuses WHERE bonuses.weapon_category=item.category) AS bonus_count
(SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=2) AS talent_2_count
FROM weapons item
LEFT JOIN weapon_tier_ranges native ON native.weapon_id=item.id AND native.tier=item.native_tier
LEFT JOIN managed_images render ON render.source_entity_type='weapon_render' AND render.source_entity_id=item.id
@@ -386,7 +389,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
rowsSql = `SELECT item.*,weapons.name AS exotic_weapon,
render.id AS render_id,render.updated_at AS render_updated_at,
GROUP_CONCAT(DISTINCT categories.weapon_category) AS categories,
(SELECT COUNT(*) FROM weapon_mod_secondary_options secondary WHERE secondary.weapon_mod_id=item.id) AS secondary_count,
(SELECT COUNT(*) FROM weapon_mod_secondary_options) AS secondary_count,
(SELECT COUNT(*) FROM weapon_mod_effects effects WHERE effects.weapon_mod_id=item.id) AS effect_count
FROM weapon_mods item
LEFT JOIN weapons ON weapons.id=item.exotic_weapon_id
@@ -398,7 +401,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
case 'gear-mods':
countSql = `SELECT COUNT(*) AS total FROM gear_mods item WHERE item.active=1${searchClause}`;
rowsSql = `SELECT item.*,render.id AS render_id,render.updated_at AS render_updated_at,
(SELECT COUNT(*) FROM gear_mod_secondary_options secondary WHERE secondary.gear_mod_id=item.id) AS secondary_count,
(SELECT COUNT(*) FROM gear_mod_secondary_options) AS secondary_count,
(SELECT MIN(primary_level_1_min) FROM gear_mod_quality_values quality WHERE quality.gear_mod_id=item.id) AS level_1_floor,
(SELECT MAX(primary_level_20_max) FROM gear_mod_quality_values quality WHERE quality.gear_mod_id=item.id) AS level_20_ceiling
FROM gear_mods item
@@ -409,7 +412,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
case 'os-protocols':
countSql = `SELECT COUNT(*) AS total FROM os_protocols item WHERE item.active=1${searchClause}`;
rowsSql = `SELECT item.*,attributes.name AS primary_stat,attributes.unit AS primary_unit,
primary_effect.base_value,talents.name AS talent_name,icons.updated_at AS icon_updated_at,
primary_effect.base_value,talents.name AS talent_name,talents.description_template AS talent_description,icons.updated_at AS icon_updated_at,
render.id AS render_id,render.updated_at AS render_updated_at
FROM os_protocols item
LEFT JOIN os_protocol_primary_effects primary_effect ON primary_effect.protocol_id=item.id
@@ -500,8 +503,8 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE item.active=1${searchClause}`;
rowsSql = `SELECT item.*,bonus.min_value,bonus.max_value,attributes.name AS bonus_stat,attributes.unit AS bonus_unit,
talents.name AS talent_name,abilities.name AS ability_name,specializations.name AS specialization_name,
(SELECT COUNT(*) FROM skill_chip_primary_options options WHERE options.skill_chip_set_id=item.id) AS primary_count,
(SELECT COUNT(*) FROM skill_chip_secondary_options options WHERE options.skill_chip_set_id=item.id) AS secondary_count,
(SELECT COUNT(*) FROM skill_chip_primary_options) AS primary_count,
(SELECT COUNT(*) FROM skill_chip_secondary_options) AS secondary_count,
icons.updated_at AS icon_updated_at,render.id AS render_id,render.updated_at AS render_updated_at
FROM skill_chip_sets item
LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id
@@ -549,6 +552,17 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
const rows = await (
await database.prepare(rowsSql)
).all(...queryArgs, pagination.perPage, pagination.offset);
const protocolTalentEffects =
category.slug === 'os-protocols' && rows.length
? await (
await database.prepare(`SELECT talents.protocol_id,effects.base_value,attributes.unit
FROM os_protocol_talents talents
JOIN os_protocol_talent_effects effects ON effects.talent_id=talents.id
JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id
WHERE talents.protocol_id IN (${rows.map(() => '?').join(',')})
ORDER BY talents.protocol_id,effects.sort_order`)
).all(...rows.map((row) => row.id))
: [];
const weaponRelations =
category.slug === 'weapons' && rows.length
? await (
@@ -563,6 +577,22 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
ORDER BY options.talent_slot,talents.name COLLATE NOCASE`)
).all(...rows.map((row) => row.id))
: [];
const weaponModSlotRows =
category.slug === 'weapons' && rows.length
? await (
await database.prepare(`SELECT weapon_id,tier,slot_type FROM weapon_mod_slots
WHERE weapon_id IN (${rows.map(() => '?').join(',')})
ORDER BY weapon_id,tier,slot_type`)
).all(...rows.map((row) => row.id))
: [];
const weaponModSlotIconRows =
category.slug === 'weapons'
? await (
await database.prepare(
"SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='weapon_mod_slot'",
)
).all()
: [];
const gearRelations =
category.slug === 'gear' && rows.length
? await (
@@ -769,6 +799,36 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
const relatedTalents = weaponRelations.filter(
(relation) => relation.weapon_id === row.id,
);
const modSlots = publicWeaponModSlots(
Number(row.native_tier),
[0, 1, 2].map((tier) => ({
tier,
slots: weaponModSlotRows
.filter(
(slot) =>
slot.weapon_id === row.id && Number(slot.tier) === tier,
)
.map((slot) => String(slot.slot_type)),
})),
).map((slot) => {
const definition = weaponModSlotDefinitionByName(slot.slot);
const icon = definition
? weaponModSlotIconRows.find(
(row) => row.entity_id === definition.id,
)
: null;
return {
...slot,
iconUrl:
definition && icon
? gameDataIconUrl(
'weapon_mod_slot',
definition.id,
icon.updated_at,
)
: '',
};
});
const talentItems = (slot: number) =>
relatedTalents
.filter((relation) => Number(relation.talent_slot) === slot)
@@ -801,11 +861,11 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
? managedImageUrl(String(row.render_id), row.render_updated_at)
: '',
gameIcon: weaponIcon(row.category),
headerBadge: `T${row.native_tier}`,
headerBadges: [
`T${row.native_tier}`,
...(Number(row.is_exotic) ? ['EXOTIC'] : []),
],
headerMetaIcon: weaponIcon(row.damage_type),
badges: Number(row.is_exotic)
? [{ label: 'EXOTIC', tone: 'exotic' }]
: [],
factLayout: 'stack' as const,
facts: [
{
@@ -831,7 +891,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
],
},
],
lines: [{ label: 'BASE BONUSES', value: String(row.bonus_count) }],
weaponModSlots: modSlots,
iconGroups: [
{
label: 'TALENT 1 POOL',
@@ -914,28 +974,28 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
? managedImageUrl(String(row.render_id), row.render_updated_at)
: '',
iconUrl: iconUrl('os_protocol', row.id, row.icon_updated_at),
badges: [
{
label: String(row.quality),
tone: String(row.quality).toLowerCase().replace('-', ''),
},
],
facts: [
{ label: 'LEVELS', value: '01–50' },
{
label: 'PRIMARY EFFECT',
value: row.primary_stat
? `${row.primary_stat} · ${valueWithUnit(row.base_value, row.primary_unit)}`
: 'NOT CONFIGURED',
},
{ label: 'TALENT UPGRADES', value: '0–5' },
],
lines: [
{
label: 'UNIQUE TALENT',
value: list(row.talent_name, 'NOT CONFIGURED'),
},
],
topBorderTone: String(row.quality).toLowerCase().replace('-', ''),
headerBadge: String(row.quality).toUpperCase(),
headerBadgeTone: String(row.quality).toLowerCase().replace('-', ''),
factLayout: 'stack' as const,
facts: [],
protocolPreview: {
primaryStat: String(row.primary_stat ?? 'NOT CONFIGURED'),
primaryUnit: String(row.primary_unit ?? ''),
primaryBaseValue: Number(row.base_value),
talentDescription: row.talent_description
? String(row.talent_description)
: undefined,
talentEffects: protocolTalentEffects
.filter((effect) => effect.protocol_id === row.id)
.map((effect) => ({
baseValue: Number(effect.base_value),
unit: String(effect.unit ?? ''),
})),
},
description: row.talent_description
? String(row.talent_description)
: undefined,
};
case 'talents':
return talentCardItem(row, mechanicsFor(row.id));
+6 -15
View File
@@ -34,8 +34,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'gearMax',
'weaponMin',
'weaponMax',
'chipMin',
'chipMax',
'capValue',
],
},
@@ -47,8 +45,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
gear_max: stat.gearMax || null,
weapon_min: stat.weaponMin || null,
weapon_max: stat.weaponMax || null,
skill_chip_min: stat.chipMin || null,
skill_chip_max: stat.chipMax || null,
cap_value: stat.capValue || null,
}));
const summary = await (
@@ -211,14 +207,9 @@ export const actions: Actions = {
let values: Array<number | null>;
try {
values = [
...[
'gearMin',
'gearMax',
'weaponMin',
'weaponMax',
'chipMin',
'chipMax',
].map((key) => number(key)),
...['gearMin', 'gearMax', 'weaponMin', 'weaponMax'].map((key) =>
number(key),
),
number('capValue', false),
];
} catch {
@@ -226,7 +217,7 @@ export const actions: Actions = {
error: 'Ranges must be numbers. Optional caps cannot be negative.',
});
}
for (let index = 0; index < 6; index += 2)
for (let index = 0; index < 4; index += 2)
if (
(values[index] === null) !== (values[index + 1] === null) ||
(values[index] !== null &&
@@ -253,7 +244,7 @@ export const actions: Actions = {
if (id)
await (
await database.prepare(
`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,updated_at=? WHERE id=?`,
`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,cap_value=?,updated_at=? WHERE id=?`,
)
).run(
name,
@@ -269,7 +260,7 @@ export const actions: Actions = {
else
await (
await database.prepare(
`INSERT INTO stat_attributes(id,name,description,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
`INSERT INTO stat_attributes(id,name,description,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
)
).run(
statId,
+2 -2
View File
@@ -14,7 +14,7 @@
const categoryOptions = ['Weapon', 'Skill', 'Character'].map((value) => ({ value, label: value }));
const calculationOptions = ['Additive', 'Multiplicative'].map((value) => ({ value, label: value }));
const unitOptions = [{value:'',label:'None'},{value:'%',label:'%'},{value:'s',label:'s'},{value:'Shots',label:'Shots'},{value:'m',label:'m'}];
const blank = () => ({id:'',name:'',description:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',skill_chip_min:'',skill_chip_max:'',cap_value:''});
const blank = () => ({id:'',name:'',description:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',cap_value:''});
const value = (input: unknown) => input ?? '';
const attributeMeta = (stat: any) => stat.pending
? `PROPOSED BY ${stat.proposerName}`
@@ -32,7 +32,7 @@
</script>
{#snippet attributeEditor()}
{#if editing}
<div class="dashboard-editor attribute-editor"><div class="editor-title"><div><p class="eyebrow">{editing.id?'MODIFY RECORD':'NEW RECORD'}</p><h3>{editing.id?editing.name:'ATTRIBUTE DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing=null}>×</button></div><PendingForm action="?/save" class="grid"><input type="hidden" name="id" value={editing.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={editing.name} required /></label><SegmentedChoice legend="TYPE" name="type" options={typeOptions} bind:value={editing.type} required/><SegmentedChoice legend="CATEGORY" name="category" options={categoryOptions} bind:value={editing.category} required/><SegmentedChoice legend="CALCULATION" name="calculationType" options={calculationOptions} bind:value={editing.calculation_type} required/><SegmentedChoice legend="UNIT" name="unit" options={unitOptions} bind:value={editing.unit}/><label class="field attribute-description">DESCRIPTION · OPTIONAL<textarea name="description" bind:value={editing.description} maxlength="1200" placeholder="Explain what this Attribute represents and how players encounter it."></textarea></label>{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}<div class="field range-field"><span class="label">{source[1]} RANGE · OPTIONAL</span><div><input name={`${source[0]==='skill_chip'?'chip':source[0]}Min`} type="number" step="any" value={value(editing[`${source[0]}_min`])} placeholder="MIN"/><input name={`${source[0]==='skill_chip'?'chip':source[0]}Max`} type="number" step="any" value={value(editing[`${source[0]}_max`])} placeholder="MAX"/></div></div>{/each}<label class="field">OPTIONAL CAP<input name="capValue" type="number" step="any" value={value(editing.cap_value)}/></label></div>{#if form?.error}<p class="error">{form.error}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => editing=null}>CANCEL</button><button class="primary">SAVE ATTRIBUTE →</button></div></PendingForm></div>
<div class="dashboard-editor attribute-editor"><div class="editor-title"><div><p class="eyebrow">{editing.id?'MODIFY RECORD':'NEW RECORD'}</p><h3>{editing.id?editing.name:'ATTRIBUTE DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing=null}>×</button></div><PendingForm action="?/save" class="grid"><input type="hidden" name="id" value={editing.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={editing.name} required /></label><SegmentedChoice legend="TYPE" name="type" options={typeOptions} bind:value={editing.type} required/><SegmentedChoice legend="CATEGORY" name="category" options={categoryOptions} bind:value={editing.category} required/><SegmentedChoice legend="CALCULATION" name="calculationType" options={calculationOptions} bind:value={editing.calculation_type} required/><SegmentedChoice legend="UNIT" name="unit" options={unitOptions} bind:value={editing.unit}/><label class="field attribute-description">DESCRIPTION · OPTIONAL<textarea name="description" bind:value={editing.description} maxlength="1200" placeholder="Explain what this Attribute represents and how players encounter it."></textarea></label>{#each [['gear','GEAR'],['weapon','WEAPON']] as source}<div class="field range-field"><span class="label">{source[1]} RANGE · OPTIONAL</span><div><input name={`${source[0]}Min`} type="number" step="any" value={value(editing[`${source[0]}_min`])} placeholder="MIN"/><input name={`${source[0]}Max`} type="number" step="any" value={value(editing[`${source[0]}_max`])} placeholder="MAX"/></div></div>{/each}<label class="field">OPTIONAL CAP<input name="capValue" type="number" step="any" value={value(editing.cap_value)}/></label></div>{#if form?.error}<p class="error">{form.error}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => editing=null}>CANCEL</button><button class="primary">SAVE ATTRIBUTE →</button></div></PendingForm></div>
{/if}
{/snippet}
<svelte:head><title>Attributes — SHD.build admin</title></svelte:head>

Some files were not shown because too many files have changed in this diff Show More