Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6af38024fa | ||
|
|
4777a3abd5 | ||
|
|
46f64d37b6 | ||
|
|
7ca6cb8458 | ||
|
|
57068b61cd | ||
|
|
d9924a95b9 | ||
|
|
d09b6220ee | ||
|
|
c61b9832dd |
+1
-1
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' ||
|
||||
|
||||
@@ -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('"', '""')}"`;
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
) {
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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=?`)
|
||||
|
||||
@@ -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(
|
||||
'</text><script href="https://attacker.invalid">',
|
||||
);
|
||||
expect(svg).toContain(
|
||||
'A gent <&> "one" 'two'',
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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, '&')
|
||||
.replace(/</gu, '<')
|
||||
.replace(/>/gu, '>')
|
||||
.replace(/"/gu, '"')
|
||||
.replace(/'/gu, ''');
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
>;
|
||||
}
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user