Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6af38024fa | ||
|
|
4777a3abd5 | ||
|
|
46f64d37b6 | ||
|
|
7ca6cb8458 | ||
|
|
57068b61cd |
+59
-30
@@ -769,15 +769,12 @@ CREATE TABLE IF NOT EXISTS stat_attributes (
|
|||||||
weapon_min REAL,
|
weapon_min REAL,
|
||||||
weapon_max REAL,
|
weapon_max REAL,
|
||||||
weapon_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50),
|
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,
|
cap_value REAL,
|
||||||
active INTEGER NOT NULL DEFAULT 1,
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_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((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((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)
|
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);
|
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_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),
|
delivery_duration_seconds REAL CHECK(delivery_duration_seconds IS NULL OR delivery_duration_seconds>0),
|
||||||
targeting_json TEXT,
|
targeting_json TEXT,
|
||||||
|
probability_percent REAL CHECK(probability_percent IS NULL OR (probability_percent>=0 AND probability_percent<=100)),
|
||||||
min_state_value REAL,
|
min_state_value REAL,
|
||||||
max_state_value REAL,
|
max_state_value REAL,
|
||||||
description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1),
|
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)
|
CHECK(max_value >= min_value)
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS skill_chip_primary_options (
|
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 PRIMARY KEY REFERENCES stat_attributes(id)
|
||||||
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
|
);
|
||||||
PRIMARY KEY(skill_chip_set_id, stat_attribute_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 (
|
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 PRIMARY KEY REFERENCES stat_attributes(id)
|
||||||
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
|
);
|
||||||
PRIMARY KEY(skill_chip_set_id, stat_attribute_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 (
|
CREATE TABLE IF NOT EXISTS skill_chip_talents (
|
||||||
id TEXT PRIMARY KEY,
|
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)
|
CHECK(primary_level_20_max>=primary_level_1_max)
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS weapon_mod_secondary_options (
|
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 PRIMARY KEY REFERENCES stat_attributes(id)
|
||||||
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
|
|
||||||
PRIMARY KEY(weapon_mod_id,stat_attribute_id)
|
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS weapon_mod_secondary_quality_values (
|
CREATE TABLE IF NOT EXISTS weapon_mod_secondary_quality_values (
|
||||||
weapon_mod_id TEXT NOT NULL,
|
|
||||||
stat_attribute_id TEXT NOT NULL,
|
stat_attribute_id TEXT NOT NULL,
|
||||||
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
|
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
|
||||||
min_value REAL NOT NULL,
|
level_1_min REAL NOT NULL,
|
||||||
max_value REAL NOT NULL,
|
level_1_max REAL NOT NULL,
|
||||||
PRIMARY KEY(weapon_mod_id,stat_attribute_id,quality),
|
level_20_min REAL NOT NULL,
|
||||||
FOREIGN KEY(weapon_mod_id,stat_attribute_id) REFERENCES weapon_mod_secondary_options(weapon_mod_id,stat_attribute_id) ON DELETE CASCADE,
|
level_20_max REAL NOT NULL,
|
||||||
CHECK(max_value>=min_value)
|
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 (
|
CREATE TABLE IF NOT EXISTS weapon_mod_exotic_attributes (
|
||||||
id TEXT PRIMARY KEY,
|
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)
|
CHECK(primary_level_20_max>=primary_level_1_max)
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS gear_mod_secondary_options (
|
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 PRIMARY KEY REFERENCES stat_attributes(id)
|
||||||
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
|
|
||||||
PRIMARY KEY(gear_mod_id,stat_attribute_id)
|
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS gear_mod_secondary_quality_values (
|
CREATE TABLE IF NOT EXISTS gear_mod_secondary_quality_values (
|
||||||
gear_mod_id TEXT NOT NULL,
|
|
||||||
stat_attribute_id TEXT NOT NULL,
|
stat_attribute_id TEXT NOT NULL,
|
||||||
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
|
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
|
||||||
min_value REAL NOT NULL,
|
level_1_min REAL NOT NULL,
|
||||||
max_value REAL NOT NULL,
|
level_1_max REAL NOT NULL,
|
||||||
PRIMARY KEY(gear_mod_id,stat_attribute_id,quality),
|
level_20_min REAL NOT NULL,
|
||||||
FOREIGN KEY(gear_mod_id,stat_attribute_id) REFERENCES gear_mod_secondary_options(gear_mod_id,stat_attribute_id) ON DELETE CASCADE,
|
level_20_max REAL NOT NULL,
|
||||||
CHECK(max_value>=min_value)
|
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 (
|
CREATE TABLE IF NOT EXISTS specialization_progression_config (
|
||||||
id TEXT PRIMARY KEY CHECK(id='default'),
|
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)
|
CHECK(charge_policy<>'none' OR charge_count=1)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS specialization_abilities_specialization_idx ON specialization_abilities(specialization_id,name);
|
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 (
|
CREATE TABLE IF NOT EXISTS specialization_ability_effects (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
|
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)
|
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');
|
VALUES('calculation-stage-cycle-1','calculation-revision-1','firing_cycle','Firing cycle','firing_cycle',9,'last');
|
||||||
CREATE TABLE IF NOT EXISTS game_data_icons (
|
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,
|
entity_id TEXT NOT NULL,
|
||||||
storage_key TEXT NOT NULL UNIQUE,
|
storage_key TEXT NOT NULL UNIQUE,
|
||||||
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
|
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
|
||||||
|
|||||||
+188
-56
@@ -341,17 +341,36 @@ squeezed beside long module headings on mobile.
|
|||||||
### Command Grid builder
|
### Command Grid builder
|
||||||
|
|
||||||
The production builder uses Command Grid as its loadout workbench. Keep the
|
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
|
entire build visible in a compact matrix while one connected editor configures
|
||||||
selected cell. The full-width Specialization and Focus cell is the root choice.
|
the selected cell. The editor uses two lanes when its container has enough
|
||||||
Its three supplied abilities appear directly below it, followed by two weapons,
|
width. Equipment identity and base values occupy the first lane; allocations,
|
||||||
six gear positions, and one full-width OS Protocol.
|
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
|
Matrix cells show the slot and equipped item at rest. The selected cell reveals
|
||||||
Sets, or Skill Chips. Do not replace those choices with configuration counts.
|
its compact Talent, Attribute, Brand Set, or Skill Chip summary. This
|
||||||
Selecting an ability opens its three attached Skill Chip positions. Selecting
|
progressive disclosure keeps the matrix scannable without replacing configured
|
||||||
equipment opens the searchable real-data catalog and its compatible
|
choices with counts. Selecting an ability opens its three attached Skill Chip
|
||||||
configuration controls. Do not display a prototype option that the current
|
positions. Selecting equipment opens the searchable real-data catalog and its
|
||||||
loadout schema cannot save.
|
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.
|
||||||
|
|
||||||
|
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
|
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
|
matrix cell with a compact red text indicator and status rail, then let the cell
|
||||||
@@ -361,20 +380,19 @@ and Skill DPS, encounter assumptions, activation profiles, rotations, and
|
|||||||
simulation as `COMING SOON`; they do not report calculation-profile blockers as
|
simulation as `COMING SOON`; they do not report calculation-profile blockers as
|
||||||
current player errors.
|
current player errors.
|
||||||
|
|
||||||
The builder uses three workbench columns on wide screens, moves the live summary
|
The builder uses two primary workbench columns on wide screens: the compact
|
||||||
below the matrix and editor at intermediate widths, and stacks all three regions
|
matrix and the two-lane editor. The live summary spans both columns below them.
|
||||||
on narrow screens. Every grid track and editor child must accept content
|
Narrow screens stack the matrix, editor, and summary. Every grid track and
|
||||||
shrinkage. At 560px and below, stack attribute picker/value pairs and section
|
editor child must accept content shrinkage. At 560px and below, stack Attribute
|
||||||
headings; keep the command actions in two equal columns. Validation markers use
|
picker/value pairs and section headings; keep the command actions in two equal
|
||||||
short visible labels while their full messages remain available to assistive
|
columns. Validation markers use short visible labels while their full messages
|
||||||
technology and pointer users.
|
remain available to assistive technology and pointer users.
|
||||||
|
|
||||||
Use `--shd-builder-canvas` for recessed equipment fields. Use
|
Use `--shd-builder-canvas` for recessed equipment fields. Use
|
||||||
`--shd-builder-selection` with `--shd-builder-selection-border` for the selected
|
`--shd-builder-selection` with `--shd-builder-selection-border` for the selected
|
||||||
cell. Preserve button semantics, `aria-pressed`, focus-visible outlines, pressed
|
cell. Preserve button semantics, `aria-pressed`, focus-visible outlines, pressed
|
||||||
feedback, and textual readiness states. At narrower desktop widths, keep the
|
feedback, and textual readiness states. On mobile, stack the matrix, editor, and
|
||||||
matrix and editor together and move live results below them. On mobile, stack
|
live results in that order.
|
||||||
the matrix, editor, and live results in that order.
|
|
||||||
|
|
||||||
The command header contains the required build name, local and account save
|
The command header contains the required build name, local and account save
|
||||||
actions, account visibility, and optional sharing notes. Sharing notes include a
|
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 data console
|
||||||
|
|
||||||
Skill Chip editing uses the dense ISAC staged-definition pattern: identity and
|
Place two shared configuration cards above the Skill Chip definition registry.
|
||||||
icon, 2-piece numeric bonus, mutually exclusive primary and secondary roll
|
The `SHARED PRIMARY PROFILE` card contains the fixed Skill Duration, Skill
|
||||||
pools, then 3-piece talent. Superior blue and High-end yellow identify the
|
Radius, Skill Cooldown Recovery, and Skill Intensity rows, with direct minimum
|
||||||
supported qualities; reserve orange for actions and the current interactive
|
and maximum fields for Standard, Specialized, Superior, and High-end. Its
|
||||||
state. A four-step segmented preview shows the linear zero-to-three High-end
|
summary shows the configured count for each quality and the `NOT YET DEFINED`
|
||||||
interpolation without duplicate values. At narrow widths, stack roll-pool and
|
calculation state. The `SHARED SECONDARY POOL` card contains repeatable
|
||||||
talent columns, use a two-by-two preview grid, and retain 44-pixel targets.
|
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
|
### ISAC record identity controls
|
||||||
|
|
||||||
@@ -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
|
endpoints as level-50 values. Weapon Talent 1 and Talent 2 pools use separate
|
||||||
clickable icon rails and the standard Talent quick view.
|
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
|
Record cards may end with contextual actions that connect related public
|
||||||
registries. Actions use the compact orange tactical-button treatment and encode
|
registries. Actions use the compact orange tactical-button treatment and encode
|
||||||
their destination filter in the URL. Brand cards provide a “find Gear” action
|
their destination filter in the URL. Brand cards provide a “find Gear” action
|
||||||
@@ -1970,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
|
required slot counts in text. Disabled tiers remain visibly unavailable and must
|
||||||
not submit slot assignments.
|
not submit slot assignments.
|
||||||
|
|
||||||
Quality-scaled registry editors use a compact matrix when the same numeric shape
|
Regular Weapon Mod definitions use a compact Level 1 and Level 20 primary-range
|
||||||
repeats across all four quality levels. Apply global quality colors only to the
|
matrix for Standard, Specialized, Superior, and High-end quality. Apply global
|
||||||
quality labels, retain textual quality names, and keep numeric fields neutral.
|
quality colors only to the quality labels, retain textual quality names, and
|
||||||
On narrow screens the matrix may scroll horizontally while its surrounding
|
keep numeric fields neutral. On narrow screens the matrix may scroll
|
||||||
editor and previews stack.
|
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
|
Configure one global secondary Attribute pool above the Weapon Mod registry.
|
||||||
once rather than rendering two duplicated selectors. Weapon Mods use a
|
Each repeatable row contains one searchable Attribute reference and Level 1 and
|
||||||
repeatable secondary-attribute row: one searchable attribute reference plus four
|
Level 20 minimum and maximum fields for Specialized, Superior, and High-end.
|
||||||
quality range pairs. Show that the builder selects two distinct options from the
|
Values between those endpoints use linear interpolation. Do not render Standard
|
||||||
pool. Primary core type is likewise builder-selected and is displayed as the
|
range fields because Standard has no secondary slot. State the fixed capacity in
|
||||||
three always-available core icons rather than a persisted ISAC choice.
|
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
|
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
|
and the standard pressed, focus-visible, and selected states. Selecting it must
|
||||||
@@ -1993,12 +2057,20 @@ state panel visible so the change in data semantics is unmistakable.
|
|||||||
|
|
||||||
### Gear Mod registry
|
### Gear Mod registry
|
||||||
|
|
||||||
Gear Mods reuse the compact quality matrix, level preview, and pooled
|
Gear Mods reuse the compact Level 1 and Level 20 primary-range matrix and level
|
||||||
secondary-attribute rows established by Weapon Mods. Do not render exotic,
|
preview established by regular Weapon Mods. Do not render exotic,
|
||||||
weapon-category, slot-type, or Mod Effect controls in this simpler editor.
|
weapon-category, slot-type, or Mod Effect controls in this simpler editor.
|
||||||
Present the five primary options as an always-visible informational band because
|
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
|
the build instance selects the primary; the canonical definition does not store
|
||||||
canonical definition.
|
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:
|
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
|
`1 MOD SLOT` for T0 and `2 MOD SLOTS` for T1/T2. On narrow screens the identity
|
||||||
@@ -2229,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
|
tree rows are contiguous, at least one paid node exists, and the tree uses the
|
||||||
configured available point total.
|
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
|
Compose focus identity from uploaded specialization artwork and the canonical
|
||||||
Firepower, Toughness, or Engineering icon. Keep the specialization art primary
|
Firepower, Toughness, or Engineering icon. Keep the specialization art primary
|
||||||
and place the attribute mark in a smaller corner badge. Mirror ability slots 1
|
and place the attribute mark in a smaller corner badge. Mirror ability slots 1
|
||||||
@@ -2325,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
|
field and describes the configured value as the total delivered across that
|
||||||
duration; switching back to instant clears the duration.
|
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
|
Action Family recipes are optional authoring starters. Present the recipe
|
||||||
catalog as a searchable assignment with only the parameters required by the
|
catalog as a searchable assignment with only the parameters required by the
|
||||||
selected family. `ADD RECIPE BRANCH` copies typed branches into the current
|
selected family. `ADD RECIPE BRANCH` copies typed branches into the current
|
||||||
@@ -2506,15 +2595,39 @@ anchor scrolling and sticky-state transitions.
|
|||||||
#### Builder calculation modes
|
#### Builder calculation modes
|
||||||
|
|
||||||
The current Builder and shared Build pages do not expose calculation modes or
|
The current Builder and shared Build pages do not expose calculation modes or
|
||||||
combat assumptions. They show deterministic equipped-item totals and reserve
|
combat assumptions. The Builder summary rail additionally contains one local
|
||||||
conditional Talent outcomes, weapon and Skill DPS, encounter assumptions,
|
experimental catalog baseline panel; shared Build pages do not render it. Keep
|
||||||
activation profiles, rotations, and simulation behind explicit `COMING SOON`
|
the panel in the summary rail only, with two independent cards labeled `PRIMARY`
|
||||||
panels.
|
and `SECONDARY`.
|
||||||
|
|
||||||
Loadout schema version 4 retains `maximum`, `custom`, conditional state,
|
Use the exact eyebrow `WEAPON OUTPUT / CATALOG BASELINE` and show the
|
||||||
encounter-assumption, and per-ability activation fields for compatibility. The
|
`EXPERIMENTAL` state. Each card displays the catalog weapon ID, selected damage,
|
||||||
current player UI ignores those fields. When player simulation ships, one
|
catalog RPM, magazine, reload, rated body-hit throughput, and magazine-cycle
|
||||||
required two-option `SegmentedChoice` will use these visible labels:
|
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
|
- `MAXIMUM POTENTIAL` calculates a configured ceiling. It applies conditional
|
||||||
effects at peak state and treats selected abilities as firing on cooldown
|
effects at peak state and treats selected abilities as firing on cooldown
|
||||||
@@ -2565,8 +2678,17 @@ The builder presents Specialization and Focus as the first loadout decision. The
|
|||||||
Focus sets the primary Attribute, progression tree, and three ordered abilities.
|
Focus sets the primary Attribute, progression tree, and three ordered abilities.
|
||||||
Its identity combines Specialization artwork and the primary Attribute mark.
|
Its identity combines Specialization artwork and the primary Attribute mark.
|
||||||
Each ability panel contains three fixed Skill Chip positions. A chip picker
|
Each ability panel contains three fixed Skill Chip positions. A chip picker
|
||||||
shows definitions assigned to that ability and excludes definitions assigned
|
shows the ability's three definitions. When ISAC explicitly pairs that ability
|
||||||
elsewhere.
|
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
|
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
|
ranks remain visible with their lock state. The builder permits a row after the
|
||||||
@@ -2581,11 +2703,11 @@ enabled ability. Future Skill result cards will show activation count,
|
|||||||
single-target DPS, total-target DPS, and a combined summary. They will state
|
single-target DPS, total-target DPS, and a combined summary. They will state
|
||||||
that Skills can critically hit and multi-shot but cannot headshot.
|
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
|
encounter assumptions, and per-ability activation profiles. The builder accepts
|
||||||
only the current complete structure for compatibility. Current player pages
|
only the current complete structure. Current player pages ignore the simulation
|
||||||
ignore the simulation fields. Invalid local drafts are discarded; invalid server
|
fields. The builder discards version 4 local drafts; a backed-up database
|
||||||
records are removed through a backed-up database migration.
|
migration removes version 4 server records.
|
||||||
|
|
||||||
### Calculation revision controls
|
### Calculation revision controls
|
||||||
|
|
||||||
@@ -2650,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
|
confirmation names the owning entity type. ISAC clears that source icon
|
||||||
assignment before it removes the shared file. General library uploads retain the
|
assignment before it removes the shared file. General library uploads retain the
|
||||||
`ICON` or `IMAGE` state.
|
`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
|
- **Healing:** Count each healing event. A heal-over-time effect counts each
|
||||||
tick as a separate event.
|
tick as a separate event.
|
||||||
- **Extra Health gained:** Count every Extra Health grant, including a grant
|
- **Extra Health gained:** Count every Extra Health grant, including a grant
|
||||||
received while the Agent already has Extra Health. Use
|
received while the Agent already has Extra Health. Use `extra_health_granted`
|
||||||
`extra_health_granted` for the grant action.
|
for the grant action.
|
||||||
- **Extra Health state changed:** `extra_health_changed` is Agent-scoped. Emit
|
- **Extra Health state changed:** `extra_health_changed` is Agent-scoped. Emit
|
||||||
it when the Agent receives a grant, when the remaining amount changes, and
|
it when the Agent receives a grant, when the remaining amount changes, and
|
||||||
when it is depleted to zero. Every event requires the boolean
|
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
|
`values.currentExtraHealth`, which must agree with that status. This state
|
||||||
event is distinct from the `extra_health_granted` grant action.
|
event is distinct from the `extra_health_granted` grant action.
|
||||||
- **Reloading:** Trigger on reload completion when the weapon had at least one
|
- **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 to load. A canceled reload does not count. A weapon that loads one round
|
||||||
round at a time produces one completed-reload event for each round.
|
at a time produces one completed-reload event for each round.
|
||||||
- **Taking damage:** Require Health loss. A hit absorbed by Extra Health does
|
- **Taking damage:** Require Health loss. A hit absorbed by Extra Health does
|
||||||
not count as taking damage.
|
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
|
- The benefit remains active while the Agent has at least one persistent
|
||||||
deployed Skill object.
|
deployed Skill object.
|
||||||
- Destruction or timed expiration removes that object from the active count.
|
- Destruction or timed expiration removes that object from the active count. The
|
||||||
The benefit ends when no qualifying objects remain.
|
benefit ends when no qualifying objects remain.
|
||||||
- Instant-use Skills do not qualify.
|
- Instant-use Skills do not qualify.
|
||||||
- Multiple deployed Skill objects do not multiply the benefit.
|
- 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.
|
- Moving within 30 meters of an ally who is already downed qualifies.
|
||||||
- Each qualifying presence refreshes the ten-second duration.
|
- Each qualifying presence refreshes the ten-second duration.
|
||||||
- Leaving the radius or reviving the ally does not cancel the current
|
- Leaving the radius or reviving the ally does not cancel the current ten-second
|
||||||
ten-second duration.
|
duration.
|
||||||
- The wearer does not count as an ally.
|
- The wearer does not count as an ally.
|
||||||
|
|
||||||
## Emergency Retreat
|
## 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
|
round inserted by a weapon that reloads one round at a time. A canceled reload
|
||||||
does not qualify.
|
does not qualify.
|
||||||
|
|
||||||
Resolve the Extra Health amount from the Agent's displayed Firepower when
|
Resolve the Extra Health amount from the Agent's displayed Firepower when Second
|
||||||
Second Wind activates. Include all equipped-item and active-buff contributions
|
Wind activates. Include all equipped-item and active-buff contributions in that
|
||||||
in that Firepower value:
|
Firepower value:
|
||||||
|
|
||||||
- Tier I grants Extra Health equal to 600% of displayed Firepower.
|
- Tier I grants Extra Health equal to 600% of displayed Firepower.
|
||||||
- Tier II grants Extra Health equal to 840% 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
|
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
|
five-second duration and five-second cooldown start when it activates. Expiry
|
||||||
removes only the Second Wind contribution. At the five-second boundary, the
|
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.
|
prior grant can expire as a qualifying reload completion grants a new amount. If
|
||||||
If Second Wind supplies the Agent's only Extra Health, the old amount expires
|
Second Wind supplies the Agent's only Extra Health, the old amount expires and
|
||||||
and the new amount begins at that boundary.
|
the new amount begins at that boundary.
|
||||||
|
|
||||||
## Nothing to Lose
|
## Nothing to Lose
|
||||||
|
|
||||||
Nothing to Lose uses a forward-looking deployment window:
|
Nothing to Lose uses a forward-looking deployment window:
|
||||||
|
|
||||||
- Each Health-damage instance makes its own 50% activation roll. Damage
|
- Each Health-damage instance makes its own 50% activation roll. Damage absorbed
|
||||||
absorbed by Extra Health does not roll.
|
by Extra Health does not roll.
|
||||||
- A successful activation applies to every Skill deployed during the five
|
- A successful activation applies to every Skill deployed during the five
|
||||||
seconds after that damage instance. Several Skills can qualify during the
|
seconds after that damage instance. Several Skills can qualify during the same
|
||||||
same window.
|
window.
|
||||||
- Calculate each reduction from the Skill's total cooldown after equipment
|
- Calculate each reduction from the Skill's total cooldown after equipment
|
||||||
modifiers.
|
modifiers.
|
||||||
- A Skill can receive no more than three reductions during one cooldown cycle.
|
- 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.
|
||||||
|
|||||||
+180
-68
@@ -25,12 +25,16 @@ The team builds the project in vertical slices.
|
|||||||
Server code checks authorization for each mutation. UI visibility has no effect
|
Server code checks authorization for each mutation. UI visibility has no effect
|
||||||
on server authorization.
|
on server authorization.
|
||||||
|
|
||||||
Builder and shared Build pages currently expose only deterministic totals from
|
Builder and shared Build pages expose deterministic totals from the equipped
|
||||||
the equipped items, selected rolls, fixed bonuses, inherited profiles, augments,
|
items, selected rolls, fixed bonuses, inherited profiles, augments, and active
|
||||||
and active set bonuses. Conditional Talent outcomes, weapon and Skill DPS,
|
set bonuses. Builder additionally shows an experimental catalog baseline for
|
||||||
encounter assumptions, activation profiles, rotations, and simulation remain
|
each selected weapon: body-hit throughput from selected weapon damage and
|
||||||
planned. Explicit `COMING SOON` panels identify those player-facing
|
catalog RPM, magazine, and reload values only. It is not expected or encounter
|
||||||
capabilities.
|
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
|
Weapon and gear range calculations must preserve decimal endpoints and follow
|
||||||
the tested progression rules in
|
the tested progression rules in
|
||||||
@@ -431,11 +435,13 @@ reference.
|
|||||||
|
|
||||||
# OS Protocol progression
|
# OS Protocol progression
|
||||||
|
|
||||||
OS Protocols use dedicated normalized tables because their talent progression
|
OS Protocols use dedicated normalized tables because an assigned Talent has six
|
||||||
has six calculated states (base plus five upgrades) rather than the shared
|
calculated states: its base value plus five upgrades. Each protocol owns one
|
||||||
two-tier talent model. Each protocol owns one `os_protocol_primary_effects`
|
`os_protocol_primary_effects` record and may own one `os_protocol_talents`
|
||||||
record and one `os_protocol_talents` record. Each protocol Talent owns its
|
record. The optional Talent owns its mechanic program through the shared
|
||||||
mechanic program through the shared conditional-effect tables.
|
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
|
Dedicated profile tables normalize the three secondary attributes. One
|
||||||
`os_protocol_secondary_profiles` record exists for each configured quality +
|
`os_protocol_secondary_profiles` record exists for each configured quality +
|
||||||
@@ -460,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
|
eligibility requires both a category match and the slot to exist on the equipped
|
||||||
weapon at its selected tier.
|
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
|
Exotic Weapon Mods instead reference one `exotic_weapon_id`. They do not carry
|
||||||
regular category compatibility. The builder treats them as permanent equipment
|
regular category compatibility. The builder treats them as permanent equipment
|
||||||
and lets the user change only their level. They do not use quality profiles,
|
and lets the user change only their level. They do not use quality profiles,
|
||||||
@@ -469,16 +484,21 @@ values in `weapon_mod_exotic_attributes`; intermediate values use linear
|
|||||||
interpolation.
|
interpolation.
|
||||||
|
|
||||||
All regular Weapon Mods permit Firepower, Toughness, or Engineering as a
|
All regular Weapon Mods permit Firepower, Toughness, or Engineering as a
|
||||||
builder-time primary choice. `weapon_mod_quality_values` stores the shared
|
builder-time primary choice. `weapon_mod_quality_values` stores each
|
||||||
level-1 and level-20 primary roll endpoints for each quality; primary values use
|
definition's level-1 and level-20 primary roll endpoints for each quality;
|
||||||
linear interpolation from levels 1–20.
|
primary values use linear interpolation from levels 1–20.
|
||||||
|
|
||||||
`weapon_mod_secondary_options` is the shared pool from which a build selects two
|
`weapon_mod_secondary_options` stores one global secondary Attribute pool for
|
||||||
distinct secondary attributes. Every option owns a minimum and maximum at each
|
all regular Weapon Mods. Each Attribute has Level 1 and Level 20 minimum and
|
||||||
quality through `weapon_mod_secondary_quality_values`; secondary availability is
|
maximum ranges for Specialized, Superior, and High-end quality through
|
||||||
not tied to a numbered slot in canonical data. Mod Effects are triggerless, hold
|
`weapon_mod_secondary_quality_values`; values at intermediate levels use linear
|
||||||
one or two attribute references, and store fixed per-quality values in
|
interpolation. Secondary capacity is fixed by quality: Standard has zero,
|
||||||
`weapon_mod_effect_quality_values`.
|
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
|
### Gear Mods and tier slot capacity
|
||||||
|
|
||||||
@@ -488,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
|
those endpoints use linear interpolation. A build instance selects one primary
|
||||||
attribute from Firepower, Toughness, Engineering, Health, or Armor.
|
attribute from Firepower, Toughness, Engineering, Health, or Armor.
|
||||||
|
|
||||||
The two secondary slots draw distinct attributes from one shared option pool.
|
One global Gear Mod secondary Attribute pool serves every Gear Mod definition.
|
||||||
Each available secondary attribute stores its own minimum and maximum for all
|
Each Attribute has Level 1 and Level 20 minimum and maximum ranges for
|
||||||
four qualities. Gear tier determines capacity without per-item configuration: T0
|
Specialized, Superior, and High-end quality through
|
||||||
gear has one Gear Mod slot; T1 and T2 gear have two.
|
`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
|
The `gear_mods` and `gear_mod_quality_values` tables store individual definition
|
||||||
`gear_mod_secondary_quality_values` tables store canonical data. Successful
|
data. `gear_mod_secondary_options` and `gear_mod_secondary_quality_values` store
|
||||||
mutations use `gear_mod.created`, `gear_mod.updated`, `gear_mod.archived`, and
|
the family-wide pool. Pool updates append a
|
||||||
`gear_mod.unarchived` audit actions. Contributor submissions use the `gearmods`
|
`game_data/gear_mod_secondary_pool.updated` audit event. Individual definition
|
||||||
proposal module and remain non-canonical until approved by Oversight.
|
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 quality scaling
|
||||||
|
|
||||||
Skill Chip sets support Superior and High-end chip instances. Each set owns one
|
Each Skill Chip definition belongs to one specialization ability. Each set owns
|
||||||
2-piece bonus, distinct primary and secondary stat-option pools, and one 3-piece
|
one 2-piece bonus and may own one 3-piece Talent with a typed conditional rule.
|
||||||
Talent with a typed conditional rule. Primary and secondary options reference
|
ISAC keeps the complete Talent workspace, including its description and mechanic
|
||||||
active Attributes that define Skill Chip minimum and maximum roll values. A
|
program, inside one collapsible optional section.
|
||||||
configured chip rolls one primary and two distinct secondary stats from those
|
|
||||||
pools.
|
|
||||||
|
|
||||||
Each active Skill Chip definition belongs to one active specialization ability
|
All definitions use one fixed primary profile. The profile contains Skill
|
||||||
through `skill_chip_sets.specialization_ability_id`. An ability has capacity for
|
Duration, Skill Radius, Skill Cooldown Recovery, and Skill Intensity, with
|
||||||
three active definitions. The save and restore actions enforce that capacity,
|
direct minimum and maximum values for Standard, Specialized, Superior, and
|
||||||
and the schema requires the foreign key for each row. A specialization focus is
|
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
|
builder-ready only when each of its three assigned abilities has all three
|
||||||
active Skill Chip definitions. Skill Chip mutations refresh readiness for
|
active Skill Chip definitions. Skill Chip mutations refresh readiness for
|
||||||
focuses that reference both the previous and resulting ability.
|
focuses that reference both the previous and resulting ability.
|
||||||
|
|
||||||
Both set-bonus progression systems use the number of equipped High-end chips as
|
`specialization_ability_skill_chip_links` may pair two active abilities from the
|
||||||
the interpolation step. For `h` High-end chips, where `h` is zero through three,
|
same specialization. Operators create each pair explicitly; the application does
|
||||||
a configured minimum and maximum resolve as
|
not infer compatibility from ability names, focus membership, or shared
|
||||||
`minimum + (maximum − minimum) × h / 3`. Zero High-end chips therefore produce
|
specialization. The pair is bidirectional, and an ability may belong to only one
|
||||||
the minimum, while three produce the maximum. The editor stores endpoints and
|
pair. This permits compatible abilities such as two Tactical Link variants to
|
||||||
previews all four states, preventing duplicated tier data from diverging. The
|
share definitions without making unrelated pairs such as two Smart Cover
|
||||||
`game_data` audit stream captures Skill Chip mutations, owned relationships, and
|
variants compatible. Archiving either ability removes the pair; restoring an
|
||||||
safe icon metadata.
|
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
|
# Mechanic programs and loadout state
|
||||||
|
|
||||||
@@ -542,6 +606,9 @@ state, with sprinting taking precedence. Enabled Skill activation profiles emit
|
|||||||
the deployed ability. An `on_event` Skill modifier applies to that activation
|
the deployed ability. An `on_event` Skill modifier applies to that activation
|
||||||
until the ability's next deployment or the encounter ends.
|
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:
|
Each source supplies a fixed value-variant contract:
|
||||||
|
|
||||||
- Standard Talents require Tier I and Tier II values. Exotic Talents require
|
- Standard Talents require Tier I and Tier II values. Exotic Talents require
|
||||||
@@ -577,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
|
event registry publishes authoritative field metadata where branches need a
|
||||||
bounded contract. For those events, the mechanic editor derives its event-field
|
bounded contract. For those events, the mechanic editor derives its event-field
|
||||||
controls from that metadata. For example, `weapon_hit.values.headshot` is a
|
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
|
`extra_health_changed` is Agent-scoped. Normalized timeline producers emit it
|
||||||
when the Agent receives an Extra Health grant, when the remaining amount
|
when the Agent receives an Extra Health grant, when the remaining amount
|
||||||
@@ -612,7 +695,10 @@ The program graph uses three child tables:
|
|||||||
durations, intervals, or derived values. Mutations can set, add, consume,
|
durations, intervals, or derived values. Mutations can set, add, consume,
|
||||||
clear, derive, or transfer state. A mutation may bind a target-scoped pool to
|
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
|
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,
|
- `conditional_effect_outcomes` applies Attribute modifiers, direct damage,
|
||||||
restoration, resource costs, or markers. Each outcome names its target scope,
|
restoration, resource costs, or markers. Each outcome names its target scope,
|
||||||
value basis, optional resource or Attribute, combat timing, optional state
|
value basis, optional resource or Attribute, combat timing, optional state
|
||||||
@@ -621,15 +707,37 @@ The program graph uses three child tables:
|
|||||||
required by the owning source. The common value models are static values, live
|
required by the owning source. The common value models are static values, live
|
||||||
Attribute values, percentage-normalized Attribute values, event-field values,
|
Attribute values, percentage-normalized Attribute values, event-field values,
|
||||||
reducer output, source-defined scenario values, and OS Protocol upgrade
|
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.
|
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
|
The generic runtime in `src/lib/calculations/mechanic.ts` processes combat
|
||||||
events in timestamp order. Predicates decide whether an event enters a clause.
|
events in timestamp order. Predicates decide whether an event enters a clause.
|
||||||
Reducers convert event streams into activations or derived values. The runtime
|
Reducers convert event streams into activations or derived values. The runtime
|
||||||
applies state mutations, enforces pool bounds and expiry, emits outcomes in
|
applies state mutations, enforces pool bounds and expiry, emits outcomes in
|
||||||
phase order, and records telemetry for each activation.
|
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,
|
Each write to an expiring pool refreshes its timer. When a pool expires,
|
||||||
`on_exit` outcomes evaluate stack thresholds and per-stack expressions against
|
`on_exit` outcomes evaluate stack thresholds and per-stack expressions against
|
||||||
the final value before reset. The runtime records the bound target on emitted
|
the final value before reset. The runtime records the bound target on emitted
|
||||||
@@ -647,11 +755,13 @@ substitute zero.
|
|||||||
The read-only `combat.currentHealthPercent` reference divides resolved current
|
The read-only `combat.currentHealthPercent` reference divides resolved current
|
||||||
Health by resolved maximum Health and returns a value from 0 through 100. The
|
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
|
runtime derives it when a predicate is evaluated instead of storing a second
|
||||||
Health value that could become stale. Current timing observes deterministic
|
Health value that could become stale. Pre-event timing reads the combat snapshot
|
||||||
resource changes from earlier branches at the same event. Health-band mechanics
|
before mechanic branches process the event. Current timing observes
|
||||||
use strict upper bounds and inclusive lower bounds, so bands do not overlap and
|
deterministic resource changes from earlier branches at the same event.
|
||||||
an exact threshold enters the next weaker band. Maximum-potential evaluation
|
Health-band mechanics use strict upper bounds and inclusive lower bounds, so
|
||||||
applies the same predicates before emitting passive outcomes.
|
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,
|
Continuous restoration resolves the outcome's total when the branch activates,
|
||||||
then delivers that total at a constant rate over the configured duration.
|
then delivers that total at a constant rate over the configured duration.
|
||||||
@@ -793,17 +903,18 @@ applicable, default value, and best-case value. ISAC retains these definitions
|
|||||||
for continued mechanic authoring. Current Builder and shared Build pages do not
|
for continued mechanic authoring. Current Builder and shared Build pages do not
|
||||||
render or evaluate them. Their calculation panels display `COMING SOON` instead.
|
render or evaluate them. Their calculation panels display `COMING SOON` instead.
|
||||||
|
|
||||||
Loadout schema version 4 stores one focus, selected tree ranks, nine Skill
|
Loadout schema version 6 stores one focus, selected tree ranks, nine Skill
|
||||||
Chips, a unified conditional-effect state map, encounter assumptions,
|
Chips, independent T1/T2 choices for each equipped gear and weapon Talent, a
|
||||||
per-ability activation controls, and `calculationMode`. Runtime readers accept
|
unified conditional-effect state map, encounter assumptions, per-ability
|
||||||
the exact version 4 structure. The schema retains its simulation fields for
|
activation controls, and `calculationMode`. Runtime readers accept the exact
|
||||||
compatibility, but the current player UI ignores them. A backed-up prototype
|
version 6 structure. A backed-up migration translated version 5 equipment
|
||||||
migration removes drafts, saved builds, and calibration snapshots that use
|
Talents from their previous equipment-tier-derived behavior. The schema retains
|
||||||
incompatible payloads.
|
its simulation fields, but the current player UI ignores them. Version 4 remains
|
||||||
|
obsolete prototype data.
|
||||||
|
|
||||||
## Planned player calculation modes
|
## Planned player calculation modes
|
||||||
|
|
||||||
Loadout v4 defines `maximum` and `custom` modes for the planned simulation UI:
|
Loadout v6 defines `maximum` and `custom` modes for the planned simulation UI:
|
||||||
|
|
||||||
- Maximum mode supplies each scenario input's best-case value, selects peak
|
- Maximum mode supplies each scenario input's best-case value, selects peak
|
||||||
conditional-effect state, and runs selected abilities on cooldown against
|
conditional-effect state, and runs selected abilities on cooldown against
|
||||||
@@ -925,11 +1036,12 @@ does not scale.
|
|||||||
|
|
||||||
# Game-data icon storage
|
# Game-data icon storage
|
||||||
|
|
||||||
Talents, brand sets, OS Protocols, and Skill Chips reference uploaded artwork
|
Talents, brand sets, OS Protocols, Skill Chips, Specializations, and shared
|
||||||
through the polymorphic `game_data_icons` metadata table. The shared server
|
Weapon Mod slots reference uploaded artwork through the polymorphic
|
||||||
pipeline accepts transparent SVG, PNG, WebP, and AVIF sources, rejects active
|
`game_data_icons` metadata table. The shared server pipeline accepts transparent
|
||||||
SVG content and external references, and uses Sharp’s native worker pool to
|
SVG, PNG, WebP, and AVIF sources, rejects active SVG content and external
|
||||||
rasterize and normalize each accepted icon to a transparent 256 × 256 WebP.
|
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
|
Normalized files live beneath the ignored `data/game-icons/` directory. The
|
||||||
validated `/icons/game-data/[type]/[id]` endpoint serves them with immutable
|
validated `/icons/game-data/[type]/[id]` endpoint serves them with immutable
|
||||||
|
|||||||
+555
-7
@@ -45,7 +45,8 @@ const isRoll = (value) =>
|
|||||||
isFiniteNumber(value.value);
|
isFiniteNumber(value.value);
|
||||||
const isRollPair = (value) =>
|
const isRollPair = (value) =>
|
||||||
Array.isArray(value) && value.length === 2 && value.every(isRoll);
|
Array.isArray(value) && value.length === 2 && value.every(isRoll);
|
||||||
const isGearSelection = (value) =>
|
const isTalentTier = (value) => value === 1 || value === 2;
|
||||||
|
const isLegacyGearSelection = (value) =>
|
||||||
hasExactKeys(value, [
|
hasExactKeys(value, [
|
||||||
'itemId',
|
'itemId',
|
||||||
'level',
|
'level',
|
||||||
@@ -68,7 +69,7 @@ const isGearSelection = (value) =>
|
|||||||
isRollPair(value.rolls) &&
|
isRollPair(value.rolls) &&
|
||||||
Array.isArray(value.augmentSlots) &&
|
Array.isArray(value.augmentSlots) &&
|
||||||
value.augmentSlots.every(isFiniteNumber);
|
value.augmentSlots.every(isFiniteNumber);
|
||||||
const isWeaponSelection = (value) =>
|
const isLegacyWeaponSelection = (value) =>
|
||||||
hasExactKeys(value, [
|
hasExactKeys(value, [
|
||||||
'itemId',
|
'itemId',
|
||||||
'level',
|
'level',
|
||||||
@@ -88,12 +89,68 @@ const isWeaponSelection = (value) =>
|
|||||||
(key) => isFiniteNumber(value[key]),
|
(key) => isFiniteNumber(value[key]),
|
||||||
) &&
|
) &&
|
||||||
isRollPair(value.rolls);
|
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) =>
|
const isChipSelection = (value) =>
|
||||||
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
|
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
|
||||||
typeof value.setId === 'string' &&
|
typeof value.setId === 'string' &&
|
||||||
(value.quality === 'Superior' || value.quality === 'High-end') &&
|
['Standard', 'Specialized', 'Superior', 'High-end'].includes(value.quality) &&
|
||||||
isRoll(value.primary) &&
|
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) =>
|
const isProtocolSelection = (value) =>
|
||||||
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) &&
|
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) &&
|
||||||
typeof value.itemId === 'string' &&
|
typeof value.itemId === 'string' &&
|
||||||
@@ -185,7 +242,7 @@ const isCurrentLoadoutSnapshot = (raw) => {
|
|||||||
'skillActivations',
|
'skillActivations',
|
||||||
'specialization',
|
'specialization',
|
||||||
]) &&
|
]) &&
|
||||||
value.version === 4 &&
|
value.version === 6 &&
|
||||||
typeof value.buildName === 'string' &&
|
typeof value.buildName === 'string' &&
|
||||||
['maximum', 'custom'].includes(value.calculationMode) &&
|
['maximum', 'custom'].includes(value.calculationMode) &&
|
||||||
hasExactKeys(value.gear, gearSlots) &&
|
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(
|
const databasePath = resolve(
|
||||||
process.env.TDR_DATABASE_PATH || './data/shd-build.db',
|
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(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 (
|
const articleRevisionColumns = await (
|
||||||
await database.prepare("PRAGMA table_info('article_revisions')")
|
await database.prepare("PRAGMA table_info('article_revisions')")
|
||||||
).all();
|
).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 (
|
const managedImageColumns = await (
|
||||||
await database.prepare("PRAGMA table_info('managed_images')")
|
await database.prepare("PRAGMA table_info('managed_images')")
|
||||||
).all();
|
).all();
|
||||||
@@ -2433,6 +2597,7 @@ const iconLibraryCategory = {
|
|||||||
specialization: 'Specializations',
|
specialization: 'Specializations',
|
||||||
specialization_ability: 'Specializations',
|
specialization_ability: 'Specializations',
|
||||||
specialization_node: 'Specializations',
|
specialization_node: 'Specializations',
|
||||||
|
weapon_mod_slot: 'Weapon Mods',
|
||||||
};
|
};
|
||||||
const iconLibraryLabel = {
|
const iconLibraryLabel = {
|
||||||
talent: 'Talent',
|
talent: 'Talent',
|
||||||
@@ -2442,6 +2607,17 @@ const iconLibraryLabel = {
|
|||||||
specialization: 'Specialization',
|
specialization: 'Specialization',
|
||||||
specialization_ability: 'Specialization Ability',
|
specialization_ability: 'Specialization Ability',
|
||||||
specialization_node: 'Specialization Tree Node',
|
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) =>
|
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:
|
default:
|
||||||
return { name: null, tags: [] };
|
return { name: null, tags: [] };
|
||||||
}
|
}
|
||||||
@@ -2912,6 +3095,302 @@ if (
|
|||||||
await database.exec('PRAGMA foreign_keys=ON');
|
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 (
|
const buildColumns = await (
|
||||||
await database.prepare("PRAGMA table_info('builds')")
|
await database.prepare("PRAGMA table_info('builds')")
|
||||||
).all();
|
).all();
|
||||||
@@ -2926,9 +3405,34 @@ if (buildColumns.length) {
|
|||||||
await database.exec(
|
await database.exec(
|
||||||
"UPDATE builds SET visibility='private' WHERE visibility='draft'",
|
"UPDATE builds SET visibility='private' WHERE visibility='draft'",
|
||||||
);
|
);
|
||||||
const builds = await (
|
let builds = await (
|
||||||
await database.prepare('SELECT id,loadout_json FROM builds')
|
await database.prepare('SELECT id,loadout_json FROM builds')
|
||||||
).all();
|
).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
|
const obsoleteBuildIds = builds
|
||||||
.filter((build) => !isCurrentLoadoutSnapshot(build.loadout_json))
|
.filter((build) => !isCurrentLoadoutSnapshot(build.loadout_json))
|
||||||
.map((build) => String(build.id));
|
.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(
|
await database.prepare(
|
||||||
'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases',
|
'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases',
|
||||||
)
|
)
|
||||||
).all();
|
).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
|
const obsoleteCalibrationIds = calibrationCases
|
||||||
.filter(
|
.filter(
|
||||||
(calibrationCase) =>
|
(calibrationCase) =>
|
||||||
|
|||||||
+72
-2
@@ -44,21 +44,91 @@ for (const table of backup.tables) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let restoredRows = 0;
|
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');
|
await database.exec('BEGIN');
|
||||||
try {
|
try {
|
||||||
for (const table of backup.tables) {
|
for (const table of backup.tables) {
|
||||||
for (const row of table.rows) {
|
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;
|
if (!columns.length) continue;
|
||||||
const columnSql = columns.map(quoteIdentifier).join(', ');
|
const columnSql = columns.map(quoteIdentifier).join(', ');
|
||||||
const placeholders = columns.map(() => '?').join(', ');
|
const placeholders = columns.map(() => '?').join(', ');
|
||||||
const insert = await database.prepare(
|
const insert = await database.prepare(
|
||||||
`INSERT INTO ${quoteIdentifier(table.name)} (${columnSql}) VALUES (${placeholders})`,
|
`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++;
|
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');
|
await database.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await database.exec('ROLLBACK');
|
await database.exec('ROLLBACK');
|
||||||
|
|||||||
@@ -98,8 +98,6 @@ try {
|
|||||||
weapon_min: record.weaponRange?.[0] ?? existing?.weapon_min ?? null,
|
weapon_min: record.weaponRange?.[0] ?? existing?.weapon_min ?? null,
|
||||||
weapon_max: record.weaponRange?.[1] ?? existing?.weapon_max ?? null,
|
weapon_max: record.weaponRange?.[1] ?? existing?.weapon_max ?? null,
|
||||||
weapon_reference_level: Number(existing?.weapon_reference_level ?? 50),
|
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,
|
cap_value: existing?.cap_value ?? null,
|
||||||
active: 1,
|
active: 1,
|
||||||
created_at: String(existing?.created_at ?? now),
|
created_at: String(existing?.created_at ?? now),
|
||||||
@@ -117,7 +115,7 @@ try {
|
|||||||
await (
|
await (
|
||||||
await database.prepare(`UPDATE stat_attributes SET
|
await database.prepare(`UPDATE stat_attributes SET
|
||||||
name=?,description=?,type=?,category=?,calculation_type=?,unit=?,
|
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=?`)
|
cap_value=?,active=1,updated_at=? WHERE id=?`)
|
||||||
).run(
|
).run(
|
||||||
next.name,
|
next.name,
|
||||||
@@ -130,8 +128,6 @@ try {
|
|||||||
next.gear_max,
|
next.gear_max,
|
||||||
next.weapon_min,
|
next.weapon_min,
|
||||||
next.weapon_max,
|
next.weapon_max,
|
||||||
next.skill_chip_min,
|
|
||||||
next.skill_chip_max,
|
|
||||||
next.cap_value,
|
next.cap_value,
|
||||||
now,
|
now,
|
||||||
id,
|
id,
|
||||||
@@ -142,8 +138,8 @@ try {
|
|||||||
await database.prepare(`INSERT INTO stat_attributes(
|
await database.prepare(`INSERT INTO stat_attributes(
|
||||||
id,name,description,type,category,calculation_type,unit,
|
id,name,description,type,category,calculation_type,unit,
|
||||||
gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level,
|
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
|
cap_value,active,created_at,updated_at
|
||||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`)
|
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`)
|
||||||
).run(
|
).run(
|
||||||
id,
|
id,
|
||||||
next.name,
|
next.name,
|
||||||
@@ -158,8 +154,6 @@ try {
|
|||||||
next.weapon_min,
|
next.weapon_min,
|
||||||
next.weapon_max,
|
next.weapon_max,
|
||||||
next.weapon_reference_level,
|
next.weapon_reference_level,
|
||||||
next.skill_chip_min,
|
|
||||||
next.skill_chip_max,
|
|
||||||
next.cap_value,
|
next.cap_value,
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { connect } from '@tursodatabase/database';
|
|||||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
import { basename, dirname, join, resolve } from 'node:path';
|
import { basename, dirname, join, resolve } from 'node:path';
|
||||||
import process from 'node:process';
|
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(
|
const databasePath = resolve(
|
||||||
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
||||||
@@ -67,7 +71,7 @@ for (const row of [
|
|||||||
|
|
||||||
const snapshot = {
|
const snapshot = {
|
||||||
format: 'shd-build-isac-data',
|
format: 'shd-build-isac-data',
|
||||||
version: 2,
|
version: ISAC_DATA_FORMAT_VERSION,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
sourceDatabase: basename(databasePath),
|
sourceDatabase: basename(databasePath),
|
||||||
totalRows,
|
totalRows,
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { spawnSync } from 'node:child_process';
|
|||||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
import { basename, join, resolve } from 'node:path';
|
import { basename, join, resolve } from 'node:path';
|
||||||
import process from 'node:process';
|
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
|
const snapshotArgument = process.argv
|
||||||
.slice(2)
|
.slice(2)
|
||||||
@@ -22,7 +26,7 @@ const snapshotPath = resolve(snapshotArgument);
|
|||||||
const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8'));
|
const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8'));
|
||||||
if (
|
if (
|
||||||
snapshot?.format !== 'shd-build-isac-data' ||
|
snapshot?.format !== 'shd-build-isac-data' ||
|
||||||
snapshot?.version !== 2 ||
|
snapshot?.version !== ISAC_DATA_FORMAT_VERSION ||
|
||||||
!Array.isArray(snapshot.tables) ||
|
!Array.isArray(snapshot.tables) ||
|
||||||
!Array.isArray(snapshot.icons)
|
!Array.isArray(snapshot.icons)
|
||||||
) {
|
) {
|
||||||
@@ -39,6 +43,233 @@ if (
|
|||||||
'The ISAC snapshot table manifest is incomplete or out of order.',
|
'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) {
|
for (const icon of snapshot.icons) {
|
||||||
if (
|
if (
|
||||||
typeof icon.storageKey !== 'string' ||
|
typeof icon.storageKey !== 'string' ||
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ export const ISAC_DATA_TABLES = [
|
|||||||
'skill_chip_sets',
|
'skill_chip_sets',
|
||||||
'skill_chip_two_piece_bonuses',
|
'skill_chip_two_piece_bonuses',
|
||||||
'skill_chip_primary_options',
|
'skill_chip_primary_options',
|
||||||
|
'skill_chip_primary_quality_values',
|
||||||
'skill_chip_secondary_options',
|
'skill_chip_secondary_options',
|
||||||
|
'skill_chip_secondary_quality_values',
|
||||||
'skill_chip_talents',
|
'skill_chip_talents',
|
||||||
'skill_chip_talent_effects',
|
'skill_chip_talent_effects',
|
||||||
'weapon_mods',
|
'weapon_mods',
|
||||||
@@ -55,6 +57,7 @@ export const ISAC_DATA_TABLES = [
|
|||||||
'specialization_combat_effects',
|
'specialization_combat_effects',
|
||||||
'specialization_focuses',
|
'specialization_focuses',
|
||||||
'specialization_abilities',
|
'specialization_abilities',
|
||||||
|
'specialization_ability_skill_chip_links',
|
||||||
'specialization_ability_attacks',
|
'specialization_ability_attacks',
|
||||||
'specialization_ability_effects',
|
'specialization_ability_effects',
|
||||||
'specialization_focus_abilities',
|
'specialization_focus_abilities',
|
||||||
@@ -73,5 +76,7 @@ export const ISAC_DATA_TABLES = [
|
|||||||
'managed_images',
|
'managed_images',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const ISAC_DATA_FORMAT_VERSION = 5;
|
||||||
|
|
||||||
export const quoteIdentifier = (identifier) =>
|
export const quoteIdentifier = (identifier) =>
|
||||||
`"${String(identifier).replaceAll('"', '""')}"`;
|
`"${String(identifier).replaceAll('"', '""')}"`;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const images = [];
|
|||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (
|
if (
|
||||||
typeof row.storage_key !== 'string' ||
|
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,
|
row.storage_key,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -14,6 +14,19 @@ if (!bundleArgument || !process.argv.includes('--confirm-merge')) {
|
|||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
const updateExisting = process.argv.includes('--update-existing');
|
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 bundlePath = resolve(bundleArgument);
|
||||||
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
|
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
|
||||||
if (
|
if (
|
||||||
@@ -30,21 +43,21 @@ for (const image of bundle.images) {
|
|||||||
typeof image?.row?.id !== 'string' ||
|
typeof image?.row?.id !== 'string' ||
|
||||||
!/^[0-9a-f-]{36}$/i.test(image.row.id) ||
|
!/^[0-9a-f-]{36}$/i.test(image.row.id) ||
|
||||||
typeof image.row.storage_key !== 'string' ||
|
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,
|
image.row.storage_key,
|
||||||
) ||
|
) ||
|
||||||
typeof image.contentBase64 !== 'string'
|
typeof image.contentBase64 !== 'string'
|
||||||
) {
|
) {
|
||||||
throw new Error('The bundle contains an invalid managed image.');
|
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,
|
image.row.source_entity_type,
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
image.row.source_entity_type &&
|
image.row.source_entity_type &&
|
||||||
(typeof image.row.source_entity_id !== 'string' ||
|
(typeof image.row.source_entity_id !== 'string' ||
|
||||||
!/^[0-9a-f-]{36}$/i.test(image.row.source_entity_id) ||
|
!/^[0-9a-f-]{36}$/i.test(image.row.source_entity_id) ||
|
||||||
(isItemRender
|
(isGameDataRender
|
||||||
? !image.row.storage_key.startsWith('library-')
|
? !image.row.storage_key.startsWith('library-')
|
||||||
: !image.row.storage_key.startsWith(
|
: !image.row.storage_key.startsWith(
|
||||||
`${image.row.source_entity_type}-`,
|
`${image.row.source_entity_type}-`,
|
||||||
|
|||||||
+814
-22
File diff suppressed because it is too large
Load Diff
@@ -91,6 +91,172 @@ describe('article documents', () => {
|
|||||||
).toBeNull();
|
).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', () => {
|
it('rejects duplicate embed keys and unknown envelope fields', () => {
|
||||||
const embed = {
|
const embed = {
|
||||||
type: 'buildEmbed',
|
type: 'buildEmbed',
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const EMBED_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{7,63}$/;
|
|||||||
|
|
||||||
export type ArticleMark = {
|
export type ArticleMark = {
|
||||||
type: 'bold' | 'italic' | 'code' | 'link';
|
type: 'bold' | 'italic' | 'code' | 'link';
|
||||||
attrs?: { href: string };
|
attrs?: { href: string; title?: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ArticleNode = {
|
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 (value === undefined) return [];
|
||||||
if (!Array.isArray(value) || value.length > 4) return null;
|
if (!Array.isArray(value) || value.length > 4) return null;
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -77,10 +87,31 @@ function parseMarks(value: unknown): ArticleMark[] | null {
|
|||||||
!isRecord(candidate.attrs)
|
!isRecord(candidate.attrs)
|
||||||
)
|
)
|
||||||
return null;
|
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);
|
const href = safeLink(candidate.attrs.href);
|
||||||
if (!href) return null;
|
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;
|
return marks;
|
||||||
}
|
}
|
||||||
@@ -134,7 +165,9 @@ export function parseArticleDocument(value: unknown): {
|
|||||||
if (/\p{Cc}/u.test(text.replace(/[\n\t]/g, ''))) return null;
|
if (/\p{Cc}/u.test(text.replace(/[\n\t]/g, ''))) return null;
|
||||||
textLength += [...text].length;
|
textLength += [...text].length;
|
||||||
if (textLength > MAX_ARTICLE_TEXT_CHARACTERS) return null;
|
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;
|
if (!marks) return null;
|
||||||
textParts.push(text);
|
textParts.push(text);
|
||||||
return marks.length ? { type, text, marks } : { type, text };
|
return marks.length ? { type, text, marks } : { type, text };
|
||||||
@@ -211,6 +244,31 @@ export function parseArticleDocument(value: unknown): {
|
|||||||
)
|
)
|
||||||
return null;
|
return null;
|
||||||
attrs = { level: Number(candidate.attrs.level) };
|
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) {
|
} else if (candidate.attrs !== undefined) {
|
||||||
if (!isRecord(candidate.attrs) || Object.keys(candidate.attrs).length)
|
if (!isRecord(candidate.attrs) || Object.keys(candidate.attrs).length)
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ describe('loadout mechanic timeline', () => {
|
|||||||
).toEqual([1, 2]);
|
).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 = {
|
const persistedRule = {
|
||||||
rule: {
|
rule: {
|
||||||
id: 'tier-i-only',
|
id: 'tier-i-only',
|
||||||
@@ -327,6 +327,7 @@ describe('loadout mechanic timeline', () => {
|
|||||||
const adapted = equippedConditionalProgram({
|
const adapted = equippedConditionalProgram({
|
||||||
key: 'weapon:0:exotic-talent',
|
key: 'weapon:0:exotic-talent',
|
||||||
tier: 2,
|
tier: 2,
|
||||||
|
isExotic: true,
|
||||||
rule: persistedRule,
|
rule: persistedRule,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ export function equippedConditionalProgram(
|
|||||||
: 'base';
|
: 'base';
|
||||||
return (
|
return (
|
||||||
conditionalProgram(equipped.rule, variant) ??
|
conditionalProgram(equipped.rule, variant) ??
|
||||||
(equipmentTalent && variant === 'II'
|
(equipmentTalent && equipped.isExotic && variant === 'II'
|
||||||
? conditionalProgram(equipped.rule, 'I')
|
? conditionalProgram(equipped.rule, 'I')
|
||||||
: null)
|
: null)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ describe('summarizeLoadout', () => {
|
|||||||
it('returns an empty deterministic summary for a partial draft', () => {
|
it('returns an empty deterministic summary for a partial draft', () => {
|
||||||
expect(summarizeLoadout(createDraft(), catalog())).toEqual({
|
expect(summarizeLoadout(createDraft(), catalog())).toEqual({
|
||||||
stats: [],
|
stats: [],
|
||||||
|
abilityStats: [],
|
||||||
activeBonuses: [],
|
activeBonuses: [],
|
||||||
equippedTalents: [],
|
equippedTalents: [],
|
||||||
});
|
});
|
||||||
@@ -206,7 +207,7 @@ describe('summarizeLoadout', () => {
|
|||||||
});
|
});
|
||||||
expect(summary.equippedTalents).toEqual([
|
expect(summary.equippedTalents).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
key: 'chips:chip-set',
|
key: 'chips:chip-set:ability',
|
||||||
tier: 1,
|
tier: 1,
|
||||||
abilityId: 'ability',
|
abilityId: 'ability',
|
||||||
description: 'Gain 25% Skill Damage.',
|
description: 'Gain 25% Skill Damage.',
|
||||||
@@ -214,6 +215,166 @@ describe('summarizeLoadout', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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', () => {
|
it('scales Protocol primary stats and Talent descriptions independently', () => {
|
||||||
const draft = createDraft();
|
const draft = createDraft();
|
||||||
draft.protocol = { itemId: 'protocol', level: 50, talentUpgrades: 5 };
|
draft.protocol = { itemId: 'protocol', level: 50, talentUpgrades: 5 };
|
||||||
@@ -322,11 +483,58 @@ describe('summarizeLoadout', () => {
|
|||||||
expect(stat(summary, 'mechanic')).toBeUndefined();
|
expect(stat(summary, 'mechanic')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the selected Talent tier without applying its mechanic outcomes to stats', () => {
|
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();
|
const draft = createDraft();
|
||||||
draft.weapons[0].itemId = 'weapon';
|
draft.weapons[0].itemId = 'weapon';
|
||||||
draft.weapons[0].tier = 2;
|
draft.weapons[0].tier = 2;
|
||||||
draft.weapons[0].talent1Id = 'talent';
|
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 = {
|
const rule = {
|
||||||
clauses: [
|
clauses: [
|
||||||
@@ -352,13 +560,13 @@ describe('summarizeLoadout', () => {
|
|||||||
{
|
{
|
||||||
id: 'weapon',
|
id: 'weapon',
|
||||||
name: 'Test Weapon',
|
name: 'Test Weapon',
|
||||||
exotic_talent_id: null,
|
exotic_talent_id: 'exotic-talent',
|
||||||
baseBonuses: [],
|
baseBonuses: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
talents: [
|
talents: [
|
||||||
{
|
{
|
||||||
id: 'talent',
|
id: 'talent-one',
|
||||||
name: 'Measured',
|
name: 'Measured',
|
||||||
template: 'Gain {1} for {2}.',
|
template: 'Gain {1} for {2}.',
|
||||||
tiers: [
|
tiers: [
|
||||||
@@ -379,17 +587,60 @@ describe('summarizeLoadout', () => {
|
|||||||
],
|
],
|
||||||
rule,
|
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(summary.equippedTalents).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: 'talent',
|
id: 'talent-one',
|
||||||
tier: 2,
|
tier: 1,
|
||||||
description: 'Gain 20% for 3 Shots.',
|
tierLabel: 'TIER I',
|
||||||
|
description: 'Gain 10% for 2 Shots.',
|
||||||
rule,
|
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();
|
expect(stat(summary, 'talent-stat')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { gearSlots, scaled, type LoadoutDraft } from '$lib/builder/loadout';
|
import {
|
||||||
|
gearSlots,
|
||||||
|
scaled,
|
||||||
|
type LoadoutDraft,
|
||||||
|
type TalentTier,
|
||||||
|
} from '$lib/builder/loadout';
|
||||||
|
|
||||||
export type LoadoutCatalog = {
|
export type LoadoutCatalog = {
|
||||||
attributes: any[];
|
attributes: any[];
|
||||||
@@ -7,6 +12,10 @@ export type LoadoutCatalog = {
|
|||||||
brands: any[];
|
brands: any[];
|
||||||
weapons: any[];
|
weapons: any[];
|
||||||
talents: any[];
|
talents: any[];
|
||||||
|
skillChipProfile?: {
|
||||||
|
primaryOptions: any[];
|
||||||
|
secondaryOptions: any[];
|
||||||
|
};
|
||||||
chipSets: any[];
|
chipSets: any[];
|
||||||
protocols: any[];
|
protocols: any[];
|
||||||
specializations: any[];
|
specializations: any[];
|
||||||
@@ -14,6 +23,20 @@ export type LoadoutCatalog = {
|
|||||||
|
|
||||||
export type LoadoutSummary = {
|
export type LoadoutSummary = {
|
||||||
stats: Array<{ id: string; value: number; sourceCount: number }>;
|
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[];
|
activeBonuses: any[];
|
||||||
equippedTalents: any[];
|
equippedTalents: any[];
|
||||||
};
|
};
|
||||||
@@ -82,18 +105,21 @@ function unitValue(catalog: LoadoutCatalog, value: unknown, statId: string) {
|
|||||||
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
|
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function talentTier(catalog: LoadoutCatalog, talentId: string, tier: number) {
|
function talentTier(
|
||||||
|
catalog: LoadoutCatalog,
|
||||||
|
talentId: string,
|
||||||
|
tier: TalentTier,
|
||||||
|
) {
|
||||||
const item = catalog.talents.find((entry) => entry.id === talentId);
|
const item = catalog.talents.find((entry) => entry.id === talentId);
|
||||||
return (
|
return item?.tiers.find(
|
||||||
item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ??
|
(entry: any) => entry.tier === (tier === 2 ? 'II' : 'I'),
|
||||||
item?.tiers[0]
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function talentDescription(
|
function talentDescription(
|
||||||
catalog: LoadoutCatalog,
|
catalog: LoadoutCatalog,
|
||||||
talentId: string,
|
talentId: string,
|
||||||
tier: number,
|
tier: TalentTier,
|
||||||
) {
|
) {
|
||||||
const item = catalog.talents.find((entry) => entry.id === talentId);
|
const item = catalog.talents.find((entry) => entry.id === talentId);
|
||||||
const configured = talentTier(catalog, talentId, tier);
|
const configured = talentTier(catalog, talentId, tier);
|
||||||
@@ -139,6 +165,55 @@ export function summarizeLoadout(
|
|||||||
const brandCounts = new Map<string, number>();
|
const brandCounts = new Map<string, number>();
|
||||||
const activeBonuses: any[] = [];
|
const activeBonuses: any[] = [];
|
||||||
const equippedTalents: 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) {
|
for (const slot of gearSlots) {
|
||||||
const selection = draft.gear[slot];
|
const selection = draft.gear[slot];
|
||||||
@@ -188,12 +263,14 @@ export function summarizeLoadout(
|
|||||||
rule:
|
rule:
|
||||||
catalog.talents.find((entry) => entry.id === selection.talentId)
|
catalog.talents.find((entry) => entry.id === selection.talentId)
|
||||||
?.rule ?? null,
|
?.rule ?? null,
|
||||||
tier: Number(item.tier),
|
tier: selection.talentTier,
|
||||||
|
tierLabel: selection.talentTier === 2 ? 'TIER II' : 'TIER I',
|
||||||
|
isExotic: false,
|
||||||
source: slot,
|
source: slot,
|
||||||
description: talentDescription(
|
description: talentDescription(
|
||||||
catalog,
|
catalog,
|
||||||
selection.talentId,
|
selection.talentId,
|
||||||
Number(item.tier),
|
selection.talentTier,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -238,42 +315,98 @@ export function summarizeLoadout(
|
|||||||
roll.value,
|
roll.value,
|
||||||
`${index ? 'Secondary' : 'Primary'} weapon roll`,
|
`${index ? 'Secondary' : 'Primary'} weapon roll`,
|
||||||
);
|
);
|
||||||
for (const talentId of [
|
const equippedWeaponTalents = [
|
||||||
selection.talent1Id,
|
{
|
||||||
selection.talent2Id,
|
id: selection.talent1Id,
|
||||||
item.exotic_talent_id,
|
tier: selection.talent1Tier,
|
||||||
].filter(Boolean)) {
|
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({
|
equippedTalents.push({
|
||||||
key: `weapon:${index}:${talentId}`,
|
key: `weapon:${index}:${equippedTalent.id}`,
|
||||||
id: talentId,
|
id: equippedTalent.id,
|
||||||
rule:
|
rule:
|
||||||
catalog.talents.find((entry) => entry.id === talentId)?.rule ?? null,
|
catalog.talents.find((entry) => entry.id === equippedTalent.id)
|
||||||
tier: selection.tier,
|
?.rule ?? null,
|
||||||
|
tier: equippedTalent.tier,
|
||||||
|
tierLabel: equippedTalent.tier === 2 ? 'TIER II' : 'TIER I',
|
||||||
|
isExotic: equippedTalent.isExotic,
|
||||||
source: index ? 'Secondary weapon' : 'Primary weapon',
|
source: index ? 'Secondary weapon' : 'Primary weapon',
|
||||||
description: talentDescription(catalog, talentId, selection.tier),
|
description: talentDescription(
|
||||||
|
catalog,
|
||||||
|
equippedTalent.id,
|
||||||
|
equippedTalent.tier,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const chipGroups = new Map<
|
const chipGroups = new Map<
|
||||||
string,
|
string,
|
||||||
{ count: number; highEnd: number; item: any }
|
{ 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) {
|
for (let index = 0; index < 9; index += 1) {
|
||||||
const selection = draft.chips[index];
|
const selection = draft.chips[index];
|
||||||
const item = catalog.chipSets.find((entry) => entry.id === selection.setId);
|
const item = catalog.chipSets.find((entry) => entry.id === selection.setId);
|
||||||
if (!item) continue;
|
if (!item) continue;
|
||||||
add(
|
const slotAbilityId = String(
|
||||||
selection.primary.statId,
|
focus?.abilities?.[Math.floor(index / 3)]?.id ?? '',
|
||||||
selection.primary.value,
|
|
||||||
`Skill Chip ${index + 1} primary`,
|
|
||||||
);
|
);
|
||||||
|
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)
|
for (const roll of selection.secondary)
|
||||||
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
|
if (validChipRoll('secondaryOptions', selection.quality, roll))
|
||||||
const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item };
|
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;
|
group.count += 1;
|
||||||
if (selection.quality === 'High-end') group.highEnd += 1;
|
if (selection.quality === 'High-end') group.highEnd += 1;
|
||||||
chipGroups.set(item.id, group);
|
chipGroups.set(groupKey, group);
|
||||||
}
|
}
|
||||||
for (const group of chipGroups.values()) {
|
for (const group of chipGroups.values()) {
|
||||||
if (group.count >= 2 && group.item.twoPiece) {
|
if (group.count >= 2 && group.item.twoPiece) {
|
||||||
@@ -298,12 +431,12 @@ export function summarizeLoadout(
|
|||||||
}
|
}
|
||||||
if (group.count === 3 && group.item.talent) {
|
if (group.count === 3 && group.item.talent) {
|
||||||
equippedTalents.push({
|
equippedTalents.push({
|
||||||
key: `chips:${group.item.id}`,
|
key: `chips:${group.item.id}:${group.abilityId}`,
|
||||||
id: '',
|
id: '',
|
||||||
custom: group.item.talent,
|
custom: group.item.talent,
|
||||||
rule: group.item.talent.rule ?? null,
|
rule: group.item.talent.rule ?? null,
|
||||||
tier: group.highEnd,
|
tier: group.highEnd,
|
||||||
abilityId: group.item.specialization_ability_id ?? null,
|
abilityId: group.abilityId || null,
|
||||||
source: '3 Skill Chips',
|
source: '3 Skill Chips',
|
||||||
description: group.item.talent.description_template.replace(
|
description: group.item.talent.description_template.replace(
|
||||||
/\{(\d+)\}/g,
|
/\{(\d+)\}/g,
|
||||||
@@ -390,6 +523,28 @@ export function summarizeLoadout(
|
|||||||
catalogAttribute(catalog, right.id)?.name ?? '',
|
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,
|
activeBonuses,
|
||||||
equippedTalents,
|
equippedTalents,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,20 +1,91 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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 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', () => {
|
describe('parseLoadoutDraft', () => {
|
||||||
it('accepts a current draft and returns an independent clone', () => {
|
it('accepts a current draft and returns an independent clone', () => {
|
||||||
const draft = createDraft();
|
const draft = createDraft();
|
||||||
const parsed = parseLoadoutDraft(draft);
|
const parsed = parseLoadoutDraft(draft);
|
||||||
|
|
||||||
expect(parsed).toEqual(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?.calculationMode).toBe('maximum');
|
||||||
expect(parsed).not.toBe(draft);
|
expect(parsed).not.toBe(draft);
|
||||||
parsed!.gear.Holster.level = 1;
|
parsed!.gear.Holster.level = 1;
|
||||||
expect(draft.gear.Holster.level).toBe(50);
|
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', () => {
|
it('rejects the retired version 1 three-chip shape', () => {
|
||||||
const retired = copyDraft() as unknown as Record<string, unknown>;
|
const retired = copyDraft() as unknown as Record<string, unknown>;
|
||||||
retired.version = 1;
|
retired.version = 1;
|
||||||
@@ -23,13 +94,125 @@ describe('parseLoadoutDraft', () => {
|
|||||||
expect(parseLoadoutDraft(retired)).toBeNull();
|
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();
|
const draft = copyDraft();
|
||||||
draft.chips.pop();
|
draft.chips.pop();
|
||||||
|
|
||||||
expect(parseLoadoutDraft(draft)).toBeNull();
|
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', () => {
|
it('rejects a draft missing the unified combat profile', () => {
|
||||||
const draft = copyDraft() as unknown as Record<string, unknown>;
|
const draft = copyDraft() as unknown as Record<string, unknown>;
|
||||||
delete draft.combatProfile;
|
delete draft.combatProfile;
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
export type Roll = { statId: string; value: number };
|
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 = {
|
export type GearSelection = {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
level: number;
|
level: number;
|
||||||
brandId: string;
|
brandId: string;
|
||||||
talentId: string;
|
talentId: string;
|
||||||
|
talentTier: TalentTier;
|
||||||
health: number;
|
health: number;
|
||||||
armor: number;
|
armor: number;
|
||||||
firepower: number;
|
firepower: number;
|
||||||
@@ -21,14 +36,17 @@ export type WeaponSelection = {
|
|||||||
toughness: number;
|
toughness: number;
|
||||||
engineering: number;
|
engineering: number;
|
||||||
talent1Id: string;
|
talent1Id: string;
|
||||||
|
talent1Tier: TalentTier;
|
||||||
talent2Id: string;
|
talent2Id: string;
|
||||||
|
talent2Tier: TalentTier;
|
||||||
|
exoticTalentTier: TalentTier;
|
||||||
rolls: [Roll, Roll];
|
rolls: [Roll, Roll];
|
||||||
};
|
};
|
||||||
export type ChipSelection = {
|
export type ChipSelection = {
|
||||||
setId: string;
|
setId: string;
|
||||||
quality: 'Superior' | 'High-end';
|
quality: SkillChipQuality;
|
||||||
primary: Roll;
|
primary: Roll;
|
||||||
secondary: [Roll, Roll];
|
secondary: Roll[];
|
||||||
};
|
};
|
||||||
export type ProtocolSelection = {
|
export type ProtocolSelection = {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
@@ -68,7 +86,7 @@ export type SpecializationSelection = {
|
|||||||
nodeRanks: Record<string, number>;
|
nodeRanks: Record<string, number>;
|
||||||
};
|
};
|
||||||
export type LoadoutDraft = {
|
export type LoadoutDraft = {
|
||||||
version: 4;
|
version: 6;
|
||||||
buildName: string;
|
buildName: string;
|
||||||
calculationMode: CalculationMode;
|
calculationMode: CalculationMode;
|
||||||
gear: Record<string, GearSelection>;
|
gear: Record<string, GearSelection>;
|
||||||
@@ -105,6 +123,7 @@ export const blankGear = (): GearSelection => ({
|
|||||||
level: 50,
|
level: 50,
|
||||||
brandId: '',
|
brandId: '',
|
||||||
talentId: '',
|
talentId: '',
|
||||||
|
talentTier: 1,
|
||||||
health: 0,
|
health: 0,
|
||||||
armor: 0,
|
armor: 0,
|
||||||
firepower: 0,
|
firepower: 0,
|
||||||
@@ -122,14 +141,17 @@ export const blankWeapon = (): WeaponSelection => ({
|
|||||||
toughness: 0,
|
toughness: 0,
|
||||||
engineering: 0,
|
engineering: 0,
|
||||||
talent1Id: '',
|
talent1Id: '',
|
||||||
|
talent1Tier: 1,
|
||||||
talent2Id: '',
|
talent2Id: '',
|
||||||
|
talent2Tier: 1,
|
||||||
|
exoticTalentTier: 1,
|
||||||
rolls: [blankRoll(), blankRoll()],
|
rolls: [blankRoll(), blankRoll()],
|
||||||
});
|
});
|
||||||
export const blankChip = (): ChipSelection => ({
|
export const blankChip = (): ChipSelection => ({
|
||||||
setId: '',
|
setId: '',
|
||||||
quality: 'Superior',
|
quality: 'Standard',
|
||||||
primary: blankRoll(),
|
primary: blankRoll(),
|
||||||
secondary: [blankRoll(), blankRoll()],
|
secondary: [],
|
||||||
});
|
});
|
||||||
export const defaultCombatProfile = (): CombatProfile => ({
|
export const defaultCombatProfile = (): CombatProfile => ({
|
||||||
encounterSeconds: 60,
|
encounterSeconds: 60,
|
||||||
@@ -148,7 +170,7 @@ export const defaultCombatProfile = (): CombatProfile => ({
|
|||||||
extraHealthActive: false,
|
extraHealthActive: false,
|
||||||
});
|
});
|
||||||
export const createDraft = (): LoadoutDraft => ({
|
export const createDraft = (): LoadoutDraft => ({
|
||||||
version: 4,
|
version: 6,
|
||||||
buildName: 'Untitled loadout',
|
buildName: 'Untitled loadout',
|
||||||
calculationMode: 'maximum',
|
calculationMode: 'maximum',
|
||||||
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
|
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> =>
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
|
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);
|
keys.every((key) => key in value);
|
||||||
const isFiniteNumber = (value: unknown): value is number =>
|
const isFiniteNumber = (value: unknown): value is number =>
|
||||||
typeof value === 'number' && Number.isFinite(value);
|
typeof value === 'number' && Number.isFinite(value);
|
||||||
|
const isTalentTier = (value: unknown): value is TalentTier =>
|
||||||
|
value === 1 || value === 2;
|
||||||
const isNumberRecord = (value: unknown) =>
|
const isNumberRecord = (value: unknown) =>
|
||||||
isRecord(value) &&
|
isRecord(value) &&
|
||||||
Object.values(value).every(
|
Object.values(value).every(
|
||||||
@@ -200,6 +257,7 @@ const isGearSelection = (value: unknown): value is GearSelection =>
|
|||||||
'level',
|
'level',
|
||||||
'brandId',
|
'brandId',
|
||||||
'talentId',
|
'talentId',
|
||||||
|
'talentTier',
|
||||||
'health',
|
'health',
|
||||||
'armor',
|
'armor',
|
||||||
'firepower',
|
'firepower',
|
||||||
@@ -211,6 +269,7 @@ const isGearSelection = (value: unknown): value is GearSelection =>
|
|||||||
['itemId', 'brandId', 'talentId'].every(
|
['itemId', 'brandId', 'talentId'].every(
|
||||||
(key) => typeof value[key] === 'string',
|
(key) => typeof value[key] === 'string',
|
||||||
) &&
|
) &&
|
||||||
|
isTalentTier(value.talentTier) &&
|
||||||
['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every(
|
['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every(
|
||||||
(key) => isFiniteNumber(value[key]),
|
(key) => isFiniteNumber(value[key]),
|
||||||
) &&
|
) &&
|
||||||
@@ -228,12 +287,18 @@ const isWeaponSelection = (value: unknown): value is WeaponSelection =>
|
|||||||
'toughness',
|
'toughness',
|
||||||
'engineering',
|
'engineering',
|
||||||
'talent1Id',
|
'talent1Id',
|
||||||
|
'talent1Tier',
|
||||||
'talent2Id',
|
'talent2Id',
|
||||||
|
'talent2Tier',
|
||||||
|
'exoticTalentTier',
|
||||||
'rolls',
|
'rolls',
|
||||||
]) &&
|
]) &&
|
||||||
['itemId', 'talent1Id', 'talent2Id'].every(
|
['itemId', 'talent1Id', 'talent2Id'].every(
|
||||||
(key) => typeof value[key] === 'string',
|
(key) => typeof value[key] === 'string',
|
||||||
) &&
|
) &&
|
||||||
|
isTalentTier(value.talent1Tier) &&
|
||||||
|
isTalentTier(value.talent2Tier) &&
|
||||||
|
isTalentTier(value.exoticTalentTier) &&
|
||||||
['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every(
|
['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every(
|
||||||
(key) => isFiniteNumber(value[key]),
|
(key) => isFiniteNumber(value[key]),
|
||||||
) &&
|
) &&
|
||||||
@@ -242,9 +307,12 @@ const isChipSelection = (value: unknown): value is ChipSelection =>
|
|||||||
isRecord(value) &&
|
isRecord(value) &&
|
||||||
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
|
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
|
||||||
typeof value.setId === 'string' &&
|
typeof value.setId === 'string' &&
|
||||||
(value.quality === 'Superior' || value.quality === 'High-end') &&
|
skillChipQualities.some((quality) => value.quality === quality) &&
|
||||||
isRoll(value.primary) &&
|
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 =>
|
const isProtocolSelection = (value: unknown): value is ProtocolSelection =>
|
||||||
isRecord(value) &&
|
isRecord(value) &&
|
||||||
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) &&
|
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) &&
|
||||||
@@ -353,7 +421,7 @@ export function parseLoadoutDraft(value: unknown): LoadoutDraft | null {
|
|||||||
'skillActivations',
|
'skillActivations',
|
||||||
'specialization',
|
'specialization',
|
||||||
]) ||
|
]) ||
|
||||||
value.version !== 4 ||
|
value.version !== 6 ||
|
||||||
typeof value.buildName !== 'string' ||
|
typeof value.buildName !== 'string' ||
|
||||||
!['maximum', 'custom'].includes(String(value.calculationMode))
|
!['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 {
|
import {
|
||||||
currentHealthPercentBandPredicate,
|
currentHealthPercentBandPredicate,
|
||||||
eventFieldComparisonPredicate,
|
eventFieldComparisonPredicate,
|
||||||
|
mechanicTargetingForMode,
|
||||||
|
mechanicTargetingMode,
|
||||||
parseConditionalMechanicDraft,
|
parseConditionalMechanicDraft,
|
||||||
readCurrentHealthPercentBand,
|
readCurrentHealthPercentBand,
|
||||||
readEventFieldComparison,
|
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', () => {
|
it('rejects an unregistered Extra Health event field deterministically', () => {
|
||||||
const value = draft();
|
const value = draft();
|
||||||
value.clauses[0].event = 'extra_health_changed';
|
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', () => {
|
it('preserves pre-event timing for current Health percentage references', () => {
|
||||||
const invalid = draft();
|
const value = draft();
|
||||||
invalid.clauses[0].if = {
|
value.clauses[0].if = {
|
||||||
kind: 'compare',
|
kind: 'compare',
|
||||||
left: {
|
left: {
|
||||||
source: 'combat',
|
source: 'combat',
|
||||||
@@ -697,11 +803,13 @@ describe('conditional mechanic authoring payload', () => {
|
|||||||
operator: 'lt',
|
operator: 'lt',
|
||||||
right: 50,
|
right: 50,
|
||||||
};
|
};
|
||||||
const result = parseConditionalMechanicDraft(invalid, {
|
const result = parseConditionalMechanicDraft(value, {
|
||||||
variantKeys: ['I', 'II'],
|
variantKeys: ['I', 'II'],
|
||||||
allowedAttributeIds: new Set(['weapon-damage']),
|
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', () => {
|
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 {
|
import {
|
||||||
MECHANIC_EVENT_KINDS,
|
MECHANIC_EVENT_KINDS,
|
||||||
MECHANIC_EVENT_REGISTRY,
|
MECHANIC_EVENT_REGISTRY,
|
||||||
|
mechanicOutcomeTargetingError,
|
||||||
validateMechanicProgram,
|
validateMechanicProgram,
|
||||||
type ClauseMutation,
|
type ClauseMutation,
|
||||||
type MechanicClause,
|
type MechanicClause,
|
||||||
type MechanicEventKind,
|
type MechanicEventKind,
|
||||||
type MechanicOutcome,
|
type MechanicOutcome,
|
||||||
type MechanicOutcomeScope,
|
type MechanicOutcomeScope,
|
||||||
|
type MechanicOutcomeTargeting,
|
||||||
type MechanicProgram,
|
type MechanicProgram,
|
||||||
type MechanicResource,
|
type MechanicResource,
|
||||||
type NumericExpression,
|
type NumericExpression,
|
||||||
@@ -198,6 +200,44 @@ export function registeredEventFields(event: MechanicEventKind) {
|
|||||||
return MECHANIC_EVENT_REGISTRY[event].fields ?? [];
|
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 = {
|
export type StatePoolThresholdComparison = {
|
||||||
pool: string;
|
pool: string;
|
||||||
operator: PredicateComparisonOperator;
|
operator: PredicateComparisonOperator;
|
||||||
@@ -258,6 +298,7 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = {
|
|||||||
'maxStacks',
|
'maxStacks',
|
||||||
'targeting',
|
'targeting',
|
||||||
'descriptionSlot',
|
'descriptionSlot',
|
||||||
|
'probabilityPercent',
|
||||||
],
|
],
|
||||||
phase: ['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active'],
|
phase: ['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active'],
|
||||||
kind: [
|
kind: [
|
||||||
@@ -291,6 +332,25 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = {
|
|||||||
continuous:
|
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.',
|
'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;
|
} as const;
|
||||||
const phases = new Set<OutcomePhase>(
|
const phases = new Set<OutcomePhase>(
|
||||||
CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.phase,
|
CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.phase,
|
||||||
@@ -366,11 +426,14 @@ function isReference(value: unknown): value is ValueReference {
|
|||||||
Boolean(value.path.trim()) &&
|
Boolean(value.path.trim()) &&
|
||||||
(value.timing === undefined ||
|
(value.timing === undefined ||
|
||||||
value.timing === 'pre_event' ||
|
value.timing === 'pre_event' ||
|
||||||
value.timing === 'current') &&
|
value.timing === 'current')
|
||||||
!(value.path === 'currentHealthPercent' && value.timing === 'pre_event')
|
|
||||||
);
|
);
|
||||||
case 'equipment':
|
case 'equipment':
|
||||||
return typeof value.path === 'string' && Boolean(value.path.trim());
|
return typeof value.path === 'string' && Boolean(value.path.trim());
|
||||||
|
case 'assigned_ability':
|
||||||
|
return (
|
||||||
|
value.path === 'active' || value.path === 'remainingDurationSeconds'
|
||||||
|
);
|
||||||
case 'attribute':
|
case 'attribute':
|
||||||
return (
|
return (
|
||||||
typeof value.attributeId === 'string' &&
|
typeof value.attributeId === 'string' &&
|
||||||
@@ -527,6 +590,17 @@ function isMutation(value: unknown): value is ClauseMutation {
|
|||||||
isExpression(value.value))
|
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 (
|
return (
|
||||||
['add', 'set', 'consume', 'derive'].includes(value.kind) &&
|
['add', 'set', 'consume', 'derive'].includes(value.kind) &&
|
||||||
typeof value.pool === 'string' &&
|
typeof value.pool === 'string' &&
|
||||||
@@ -724,30 +798,21 @@ function parseOutcome(
|
|||||||
) {
|
) {
|
||||||
errors.push(`${path} description slot must be a positive integer.`);
|
errors.push(`${path} description slot must be a positive integer.`);
|
||||||
}
|
}
|
||||||
if (value.targeting !== undefined) {
|
if (
|
||||||
if (
|
value.probabilityPercent !== undefined &&
|
||||||
!object(value.targeting) ||
|
(!finite(value.probabilityPercent) ||
|
||||||
value.targeting.kind !== 'within_radius' ||
|
value.probabilityPercent < 0 ||
|
||||||
!['allies', 'enemies'].includes(String(value.targeting.population)) ||
|
value.probabilityPercent > 100)
|
||||||
!finite(value.targeting.radiusMeters) ||
|
) {
|
||||||
value.targeting.radiusMeters <= 0
|
errors.push(`${path} probability must be from 0 through 100 percent.`);
|
||||||
) {
|
|
||||||
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.`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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)) {
|
if (!object(value.variants)) {
|
||||||
errors.push(`${path} variants must be an object.`);
|
errors.push(`${path} variants must be an object.`);
|
||||||
return null;
|
return null;
|
||||||
@@ -828,6 +893,22 @@ function parseClauses(
|
|||||||
if (candidate.weight !== undefined && !isExpression(candidate.weight)) {
|
if (candidate.weight !== undefined && !isExpression(candidate.weight)) {
|
||||||
errors.push(`${path} has an invalid weight expression.`);
|
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 (
|
if (
|
||||||
candidate.cooldownSeconds !== undefined &&
|
candidate.cooldownSeconds !== undefined &&
|
||||||
(!finite(candidate.cooldownSeconds) || candidate.cooldownSeconds <= 0)
|
(!finite(candidate.cooldownSeconds) || candidate.cooldownSeconds <= 0)
|
||||||
@@ -902,6 +983,15 @@ function programsForDraft(
|
|||||||
? {}
|
? {}
|
||||||
: { cooldownSeconds: clause.cooldownSeconds }),
|
: { cooldownSeconds: clause.cooldownSeconds }),
|
||||||
...(clause.weight === undefined ? {} : { weight: clause.weight }),
|
...(clause.weight === undefined ? {} : { weight: clause.weight }),
|
||||||
|
...(clause.procChancePercent === undefined
|
||||||
|
? {}
|
||||||
|
: { procChancePercent: clause.procChancePercent }),
|
||||||
|
...(clause.requiresAssignedAbilityActive === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
requiresAssignedAbilityActive:
|
||||||
|
clause.requiresAssignedAbilityActive,
|
||||||
|
}),
|
||||||
mutations: clause.mutations,
|
mutations: clause.mutations,
|
||||||
outcomes: clause.outcomes.map(
|
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', () => {
|
it('loads continuous restoration delivery metadata', () => {
|
||||||
const result = adaptConditionalEffectProgram(
|
const result = adaptConditionalEffectProgram(
|
||||||
{
|
{
|
||||||
@@ -713,7 +850,7 @@ describe('conditional-effect persistence adapter', () => {
|
|||||||
left: {
|
left: {
|
||||||
source: 'combat',
|
source: 'combat',
|
||||||
path: 'currentHealthPercent',
|
path: 'currentHealthPercent',
|
||||||
timing: 'current',
|
timing: 'pre_event',
|
||||||
},
|
},
|
||||||
operator: 'lt',
|
operator: 'lt',
|
||||||
right: 25,
|
right: 25,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export type ConditionalEffectClauseRow = {
|
|||||||
event_kind?: DatabaseValue;
|
event_kind?: DatabaseValue;
|
||||||
recipe_key?: DatabaseValue;
|
recipe_key?: DatabaseValue;
|
||||||
parameters_json: DatabaseValue;
|
parameters_json: DatabaseValue;
|
||||||
|
recipe_parameters_json?: DatabaseValue;
|
||||||
conditions_json: DatabaseValue;
|
conditions_json: DatabaseValue;
|
||||||
reducer_json: DatabaseValue;
|
reducer_json: DatabaseValue;
|
||||||
weight_expression_json?: DatabaseValue;
|
weight_expression_json?: DatabaseValue;
|
||||||
@@ -85,6 +86,7 @@ export type ConditionalEffectOutcomeRow = {
|
|||||||
delivery_mode?: DatabaseValue;
|
delivery_mode?: DatabaseValue;
|
||||||
delivery_duration_seconds?: DatabaseValue;
|
delivery_duration_seconds?: DatabaseValue;
|
||||||
targeting_json?: DatabaseValue;
|
targeting_json?: DatabaseValue;
|
||||||
|
probability_percent?: DatabaseValue;
|
||||||
value_scaling: DatabaseValue;
|
value_scaling: DatabaseValue;
|
||||||
unit: DatabaseValue;
|
unit: DatabaseValue;
|
||||||
damage_basis: DatabaseValue;
|
damage_basis: DatabaseValue;
|
||||||
@@ -205,14 +207,16 @@ function isValueReference(value: unknown) {
|
|||||||
if (value.source === 'event' || value.source === 'equipment') {
|
if (value.source === 'event' || value.source === 'equipment') {
|
||||||
return typeof value.path === 'string' && Boolean(value.path.trim());
|
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') {
|
if (value.source === 'combat') {
|
||||||
return (
|
return (
|
||||||
typeof value.path === 'string' &&
|
typeof value.path === 'string' &&
|
||||||
Boolean(value.path.trim()) &&
|
Boolean(value.path.trim()) &&
|
||||||
(value.timing === undefined ||
|
(value.timing === undefined ||
|
||||||
value.timing === 'pre_event' ||
|
value.timing === 'pre_event' ||
|
||||||
value.timing === 'current') &&
|
value.timing === 'current')
|
||||||
!(value.path === 'currentHealthPercent' && value.timing === 'pre_event')
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (value.source === 'attribute') {
|
if (value.source === 'attribute') {
|
||||||
@@ -313,6 +317,19 @@ function isMutation(value: unknown): value is ClauseMutation {
|
|||||||
isNumericExpression(value.value))
|
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 (
|
return (
|
||||||
['add', 'set', 'consume', 'derive'].includes(value.kind) &&
|
['add', 'set', 'consume', 'derive'].includes(value.kind) &&
|
||||||
typeof value.pool === 'string' &&
|
typeof value.pool === 'string' &&
|
||||||
@@ -731,6 +748,17 @@ function parseOutcome(
|
|||||||
if (delivery === 'instant' && durationSeconds !== null) {
|
if (delivery === 'instant' && durationSeconds !== null) {
|
||||||
errors.push(`${path} instant delivery cannot set a duration.`);
|
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;
|
let targeting: MechanicOutcome['targeting'] | undefined;
|
||||||
if (
|
if (
|
||||||
row.targeting_json !== null &&
|
row.targeting_json !== null &&
|
||||||
@@ -766,6 +794,7 @@ function parseOutcome(
|
|||||||
? { delivery, durationSeconds }
|
? { delivery, durationSeconds }
|
||||||
: {}),
|
: {}),
|
||||||
...(targeting ? { targeting } : {}),
|
...(targeting ? { targeting } : {}),
|
||||||
|
...(probabilityPercent === null ? {} : { probabilityPercent }),
|
||||||
...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id)
|
...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id)
|
||||||
? { attributeId: text(row.stat_attribute_id) }
|
? { attributeId: text(row.stat_attribute_id) }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -903,7 +932,7 @@ export function adaptConditionalEffectProgram(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const parametersJson = parseJson(
|
const parametersJson = parseJson(
|
||||||
row.parameters_json,
|
row.recipe_parameters_json ?? row.parameters_json,
|
||||||
`${path} parameters`,
|
`${path} parameters`,
|
||||||
errors,
|
errors,
|
||||||
);
|
);
|
||||||
@@ -976,6 +1005,25 @@ export function adaptConditionalEffectProgram(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const recipeKey = nullableText(row.recipe_key);
|
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({
|
clauses.push({
|
||||||
id,
|
id,
|
||||||
label: text(row.label) || text(row.clause_key) || undefined,
|
label: text(row.label) || text(row.clause_key) || undefined,
|
||||||
@@ -989,6 +1037,10 @@ export function adaptConditionalEffectProgram(
|
|||||||
? {}
|
? {}
|
||||||
: { cooldownSeconds: number(row.cooldown_seconds) as number }),
|
: { cooldownSeconds: number(row.cooldown_seconds) as number }),
|
||||||
...(weight === undefined ? {} : { weight }),
|
...(weight === undefined ? {} : { weight }),
|
||||||
|
...(typeof procChancePercent === 'number' ? { procChancePercent } : {}),
|
||||||
|
...(typeof requiresAssignedAbilityActive === 'boolean'
|
||||||
|
? { requiresAssignedAbilityActive }
|
||||||
|
: {}),
|
||||||
mutations: mutationsJson.value,
|
mutations: mutationsJson.value,
|
||||||
...(clauseOutcomes.length ? { outcomes: clauseOutcomes } : {}),
|
...(clauseOutcomes.length ? { outcomes: clauseOutcomes } : {}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,276 @@ const custom = (
|
|||||||
inputs,
|
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', () => {
|
describe('structured mechanic runtime', () => {
|
||||||
it('advertises the authoritative weapon-hit target and headshot fields', () => {
|
it('advertises the authoritative weapon-hit target and headshot fields', () => {
|
||||||
expect(MECHANIC_EVENT_REGISTRY.weapon_hit).toMatchObject({
|
expect(MECHANIC_EVENT_REGISTRY.weapon_hit).toMatchObject({
|
||||||
@@ -32,6 +302,11 @@ describe('structured mechanic runtime', () => {
|
|||||||
},
|
},
|
||||||
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' },
|
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' },
|
||||||
{ path: 'values.critical', label: 'Critical hit', type: 'boolean' },
|
{ path: 'values.critical', label: 'Critical hit', type: 'boolean' },
|
||||||
|
{
|
||||||
|
path: 'values.aiming',
|
||||||
|
label: 'Aiming down sights',
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'values.behindCover',
|
path: 'values.behindCover',
|
||||||
label: 'Agent behind cover',
|
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', () => {
|
it('executes imported talent trigger families as first-class events', () => {
|
||||||
const eventKinds: MechanicEventKind[] = [
|
const eventKinds: MechanicEventKind[] = [
|
||||||
'ally_downed',
|
'ally_downed',
|
||||||
@@ -2404,13 +2834,13 @@ describe('mechanic validation and pseudo-script', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects pre-event timing for the derived current Health percentage', () => {
|
it('evaluates pre-event timing for the derived current Health percentage', () => {
|
||||||
const invalid: MechanicProgram = {
|
const program: MechanicProgram = {
|
||||||
id: 'invalid-health-percent',
|
id: 'pre-event-health-percent',
|
||||||
name: 'Invalid Health Percent',
|
name: 'Pre-event Health Percent',
|
||||||
clauses: [
|
clauses: [
|
||||||
{
|
{
|
||||||
id: 'invalid',
|
id: 'full-health-before-event',
|
||||||
when: 'tick',
|
when: 'tick',
|
||||||
if: {
|
if: {
|
||||||
kind: 'compare',
|
kind: 'compare',
|
||||||
@@ -2419,25 +2849,32 @@ describe('mechanic validation and pseudo-script', () => {
|
|||||||
path: 'currentHealthPercent',
|
path: 'currentHealthPercent',
|
||||||
timing: 'pre_event',
|
timing: 'pre_event',
|
||||||
},
|
},
|
||||||
operator: 'lt',
|
operator: 'gte',
|
||||||
right: 50,
|
right: 100,
|
||||||
},
|
},
|
||||||
reducer: { kind: 'none' },
|
reducer: { kind: 'none' },
|
||||||
mutations: [],
|
mutations: [],
|
||||||
|
outcomes: [
|
||||||
|
{
|
||||||
|
id: 'matched',
|
||||||
|
label: 'Matched',
|
||||||
|
phase: 'on_event',
|
||||||
|
kind: 'marker',
|
||||||
|
scope: 'self',
|
||||||
|
basis: 'flat',
|
||||||
|
value: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const result = validateMechanicProgram(invalid);
|
expect(validateMechanicProgram(program)).toMatchObject({ ok: true });
|
||||||
expect(result.ok).toBe(false);
|
expect(
|
||||||
if (!result.ok) {
|
evaluateMechanicProgram(program, [{ kind: 'tick', at: 0 }], custom(1), {
|
||||||
expect(result.errors).toEqual(
|
maxHealth: 100,
|
||||||
expect.arrayContaining([
|
currentHealth: 100,
|
||||||
expect.stringContaining(
|
}).outcomes.map((outcome) => outcome.outcomeId),
|
||||||
'current Health percentage must use current timing',
|
).toEqual(['matched']);
|
||||||
),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('formats nested conditions and state operations deterministically', () => {
|
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 ALLIES WITHIN 40m');
|
||||||
expect(result.script).toContain('TO ENEMIES WITHIN 20m');
|
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
@@ -23,7 +23,9 @@
|
|||||||
let coverUploading = $state(false);
|
let coverUploading = $state(false);
|
||||||
let initialized = $state(false);
|
let initialized = $state(false);
|
||||||
let lastContent = '';
|
let lastContent = '';
|
||||||
let saveSequence = 0;
|
let saveTimer: number | undefined;
|
||||||
|
let saveInFlight = false;
|
||||||
|
let saveQueued = false;
|
||||||
const groupedTags = $derived.by(() => {
|
const groupedTags = $derived.by(() => {
|
||||||
const groups: Record<string, ComposerTag[]> = {};
|
const groups: Record<string, ComposerTag[]> = {};
|
||||||
for (const tag of data.tags as ComposerTag[]) (groups[tag.group] ??= []).push(tag);
|
for (const tag of data.tags as ComposerTag[]) (groups[tag.group] ??= []).push(tag);
|
||||||
@@ -35,6 +37,40 @@
|
|||||||
coverImageId, coverAlt, coverCaption, version };
|
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(() => {
|
$effect(() => {
|
||||||
const content = JSON.stringify({ title, deck, document, selectedTags, channel, coverImageId, coverAlt, coverCaption });
|
const content = JSON.stringify({ title, deck, document, selectedTags, channel, coverImageId, coverAlt, coverCaption });
|
||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
@@ -44,29 +80,13 @@
|
|||||||
}
|
}
|
||||||
if (content === lastContent) return;
|
if (content === lastContent) return;
|
||||||
lastContent = content;
|
lastContent = content;
|
||||||
const sequence = ++saveSequence;
|
saveQueued = true;
|
||||||
saveState = 'saving';
|
saveState = 'saving';
|
||||||
const timer = window.setTimeout(async () => {
|
if (!saveInFlight) scheduleSave();
|
||||||
try {
|
return () => {
|
||||||
const response = await fetch('/api/articles/draft', {
|
if (saveTimer !== undefined) window.clearTimeout(saveTimer);
|
||||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload())
|
saveTimer = undefined;
|
||||||
});
|
};
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function toggleTag(id: string) {
|
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>
|
<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">
|
<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}/>
|
<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>
|
</PendingForm>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,16 @@
|
|||||||
function link(mark: ArticleMark) {
|
function link(mark: ArticleMark) {
|
||||||
return mark.type === 'link' ? mark.attrs?.href ?? '#' : '#';
|
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>
|
</script>
|
||||||
|
|
||||||
{#snippet renderText(text: string, marks: ArticleMark[], index = 0)}
|
{#snippet renderText(text: string, marks: ArticleMark[], index = 0)}
|
||||||
@@ -32,7 +42,7 @@
|
|||||||
{#if mark.type === 'bold'}<strong>{@render renderText(text, marks, index + 1)}</strong>
|
{#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 === 'italic'}<em>{@render renderText(text, marks, index + 1)}</em>
|
||||||
{:else if mark.type === 'code'}<code>{@render renderText(text, marks, index + 1)}</code>
|
{: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}
|
{/if}
|
||||||
{/snippet}
|
{/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' && Number(node.attrs?.level) === 2}<h2>{@render children(node)}</h2>
|
||||||
{:else if node.type === 'heading'}<h3>{@render children(node)}</h3>
|
{:else if node.type === 'heading'}<h3>{@render children(node)}</h3>
|
||||||
{:else if node.type === 'bulletList'}<ul>{@render children(node)}</ul>
|
{: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 === 'listItem'}<li>{@render children(node)}</li>
|
||||||
{:else if node.type === 'blockquote'}<blockquote>{@render children(node)}</blockquote>
|
{:else if node.type === 'blockquote'}<blockquote>{@render children(node)}</blockquote>
|
||||||
{:else if node.type === 'horizontalRule'}<hr/>
|
{:else if node.type === 'horizontalRule'}<hr/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
|
||||||
import ArticleContent from '$lib/components/ArticleContent.svelte';
|
import ArticleContent from '$lib/components/ArticleContent.svelte';
|
||||||
import ArticleDiscussion from '$lib/components/ArticleDiscussion.svelte';
|
import ArticleDiscussion from '$lib/components/ArticleDiscussion.svelte';
|
||||||
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
|
|
||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
let { data, form = null } = $props();
|
let { data, form = null } = $props();
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<header class="article-reader-head">
|
<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>
|
<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>
|
<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.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}
|
{#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>
|
</header>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
items,
|
items,
|
||||||
value = $bindable(''),
|
value = $bindable(''),
|
||||||
label,
|
label,
|
||||||
placeholder = 'Search available items…',
|
placeholder = 'Search…',
|
||||||
disabledIds = [],
|
disabledIds = [],
|
||||||
onchange
|
onchange
|
||||||
}: {
|
}: {
|
||||||
@@ -27,19 +27,19 @@
|
|||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<button type="button" class="builder-picker-current" aria-expanded={open} onclick={() => open = !open}>
|
<button type="button" class="builder-picker-current" aria-expanded={open} onclick={() => open = !open}>
|
||||||
<ItemIcon src={selected?.iconUrl} alt=""/>
|
<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>
|
</button>
|
||||||
{#if open}
|
{#if open}
|
||||||
<div class="builder-picker-panel">
|
<div class="builder-picker-panel">
|
||||||
<input type="search" bind:value={query} {placeholder} aria-label={placeholder}/>
|
<input type="search" bind:value={query} {placeholder} aria-label={placeholder}/>
|
||||||
<div>
|
<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}
|
{#each results as item}
|
||||||
<button type="button" class:selected={item.id === value} disabled={disabledIds.includes(item.id)} onclick={() => choose(item.id)}>
|
<button type="button" class:selected={item.id === value} disabled={disabledIds.includes(item.id)} onclick={() => choose(item.id)}>
|
||||||
<ItemIcon src={item.iconUrl} alt=""/>
|
<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>
|
<span><b>{item.name}</b><small>{item.category ?? 'AVAILABLE'}</small></span><em>{item.id === value ? 'EQUIPPED' : disabledIds.includes(item.id) ? 'CONFLICT' : 'SELECT'}</em>
|
||||||
</button>
|
</button>
|
||||||
{:else}<p>NO MATCHING ITEMS</p>{/each}
|
{:else}<p>NO MATCHES</p>{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let query = $state('');
|
let query = $state('');
|
||||||
|
let open = $state(false);
|
||||||
|
const selected = $derived(items.find((item) => item.id === value));
|
||||||
const visible = $derived(items.filter((item) =>
|
const visible = $derived(items.filter((item) =>
|
||||||
!query.trim() || `${item.name} ${item.category ?? ''}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())
|
!query.trim() || `${item.name} ${item.category ?? ''}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())
|
||||||
));
|
));
|
||||||
@@ -31,26 +33,33 @@
|
|||||||
function choose(id: string) {
|
function choose(id: string) {
|
||||||
value = id;
|
value = id;
|
||||||
onchange?.(id);
|
onchange?.(id);
|
||||||
|
if (searchable) {
|
||||||
|
open = false;
|
||||||
|
query = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<fieldset class="builder-radio-grid" class:icon-only={iconOnly} style={`--builder-radio-columns:${Math.max(1, columns)}`}>
|
<fieldset class="builder-radio-grid" class:icon-only={iconOnly} style={`--builder-radio-columns:${Math.max(1, columns)}`}>
|
||||||
<legend>{label}</legend>
|
<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()}`}/>
|
<input class="builder-radio-search" type="search" bind:value={query} placeholder="SEARCH AVAILABLE OPTIONS" aria-label={`Search ${label.toLocaleLowerCase()}`}/>
|
||||||
{/if}
|
{/if}
|
||||||
<div>
|
{#if !searchable || open}
|
||||||
{#each visible as item}
|
<div>
|
||||||
<label title={item.name}>
|
{#each visible as item}
|
||||||
<input type="radio" {name} value={item.id} checked={value === item.id} {required} aria-label={item.name} onchange={() => choose(item.id)}/>
|
<label title={item.name}>
|
||||||
<span>
|
<input type="radio" {name} value={item.id} checked={value === item.id} {required} aria-label={item.name} onchange={() => choose(item.id)}/>
|
||||||
<ItemIcon src={item.iconUrl} alt=""/>
|
<span>
|
||||||
<b>{item.name}</b>
|
<ItemIcon src={item.iconUrl} alt=""/>
|
||||||
{#if item.category}<small>{item.category}</small>{/if}
|
<b>{item.name}</b>
|
||||||
</span>
|
{#if item.category}<small>{item.category}</small>{/if}
|
||||||
</label>
|
</span>
|
||||||
{:else}
|
</label>
|
||||||
<p>NO MATCHING OPTIONS</p>
|
{:else}
|
||||||
{/each}
|
<p>NO MATCHING OPTIONS</p>
|
||||||
</div>
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import type { GameIconName } from '$lib/game-icons';
|
import type { GameIconName } from '$lib/game-icons';
|
||||||
import type { DatabaseItem } from '$lib/database/items';
|
import type { DatabaseItem } from '$lib/database/items';
|
||||||
import MarkdownContent from './MarkdownContent.svelte';
|
import MarkdownContent from './MarkdownContent.svelte';
|
||||||
|
import WeaponModSlotIcon from './WeaponModSlotIcon.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
item,
|
item,
|
||||||
@@ -16,9 +17,21 @@
|
|||||||
hideDiscussionUtility?: boolean;
|
hideDiscussionUtility?: boolean;
|
||||||
anchor?: boolean;
|
anchor?: boolean;
|
||||||
} = $props();
|
} = $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>
|
</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>
|
<header>
|
||||||
{#if item.renderUrl}<img class="database-item-render" src={item.renderUrl} alt="" loading="lazy"/>{/if}
|
{#if item.renderUrl}<img class="database-item-render" src={item.renderUrl} alt="" loading="lazy"/>{/if}
|
||||||
<div class="database-item-icon">
|
<div class="database-item-icon">
|
||||||
@@ -31,13 +44,38 @@
|
|||||||
{#if item.headerBadges?.length || item.headerBadge || item.headerMetaIcon}
|
{#if item.headerBadges?.length || item.headerBadge || item.headerMetaIcon}
|
||||||
<div class="database-header-status">
|
<div class="database-header-status">
|
||||||
{#if item.headerMetaIcon}<GameIcon name={item.headerMetaIcon}/>{/if}
|
{#if item.headerMetaIcon}<GameIcon name={item.headerMetaIcon}/>{/if}
|
||||||
{#each item.headerBadges ?? [] as badge}<span class="database-header-badge">{badge}</span>{/each}
|
{#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">{item.headerBadge}</span>{/if}
|
{#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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</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}
|
{#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}
|
{#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>
|
<div class="database-item-lines">{#each item.lines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -53,7 +91,7 @@
|
|||||||
<section><span>TIER I</span><p>{item.description ?? 'Description is not available.'}</p></section>
|
<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>
|
<section><span>TIER II</span><p>{item.secondaryDescription ?? 'Description is not available.'}</p></section>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else if !item.protocolPreview}
|
||||||
{#if item.description}<p class:data-description={item.descriptionStyle === 'data'}>{item.description}</p>{/if}
|
{#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 item.secondaryDescription}<p class="secondary-description" class:data-description={item.descriptionStyle === 'data'}>{item.secondaryDescription}</p>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -14,4 +14,4 @@
|
|||||||
const resolvedAlt = $derived(src || !alt ? alt : `${alt} — icon awaiting contribution`);
|
const resolvedAlt = $derived(src || !alt ? alt : `${alt} — icon awaiting contribution`);
|
||||||
</script>
|
</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',
|
gear_max: 'gearMax',
|
||||||
weapon_min: 'weaponMin',
|
weapon_min: 'weaponMin',
|
||||||
weapon_max: 'weaponMax',
|
weapon_max: 'weaponMax',
|
||||||
skill_chip_min: 'chipMin',
|
|
||||||
skill_chip_max: 'chipMax',
|
|
||||||
cap_value: 'capValue'
|
cap_value: 'capValue'
|
||||||
};
|
};
|
||||||
return proposed[names[key] ?? key];
|
return proposed[names[key] ?? key];
|
||||||
@@ -46,6 +44,8 @@
|
|||||||
);
|
);
|
||||||
const specializationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
|
const specializationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
|
||||||
const skillChipRecord = $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([
|
const skillChipFields = $derived([
|
||||||
{ label: 'NAME', current: skillChipRecord?.name, proposed: proposed.name },
|
{ label: 'NAME', current: skillChipRecord?.name, proposed: proposed.name },
|
||||||
{ label: 'SPECIALIZATION ABILITY', current: skillChipRecord?.specialization_ability_id, proposed: proposed.abilityId },
|
{ 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: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId },
|
||||||
{ label: 'DURATION · SECONDS', current: specializationRecord?.duration_seconds, proposed: proposed.durationSeconds },
|
{ label: 'DURATION · SECONDS', current: specializationRecord?.duration_seconds, proposed: proposed.durationSeconds },
|
||||||
{ label: 'COOLDOWN · SECONDS', current: specializationRecord?.cooldown_seconds, proposed: proposed.cooldownSeconds },
|
{ 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 }
|
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
|
||||||
]
|
]
|
||||||
: action === 'saveCombatEffect'
|
: action === 'saveCombatEffect'
|
||||||
@@ -256,7 +257,7 @@
|
|||||||
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
|
{#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>
|
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('current', field[0]))}</strong></div>
|
||||||
{/each}
|
{/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>
|
<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}
|
{/each}
|
||||||
<div class="preview-field"><span>OPTIONAL CAP</span><strong>{shown(attributeValue('current', 'cap_value'))}</strong></div>
|
<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}
|
{#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>
|
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('proposed', field[0]))}</strong></div>
|
||||||
{/each}
|
{/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>
|
<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}
|
{/each}
|
||||||
<div class="preview-field"><span>OPTIONAL CAP</span><strong>{shown(attributeValue('proposed', 'cap_value'))}</strong></div>
|
<div class="preview-field"><span>OPTIONAL CAP</span><strong>{shown(attributeValue('proposed', 'cap_value'))}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</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'}
|
{:else if module === 'skillchips' && action === 'save'}
|
||||||
<div class="proposal-compare-grid">
|
<div class="proposal-compare-grid">
|
||||||
<article class="proposal-editor-preview before">
|
<article class="proposal-editor-preview before">
|
||||||
@@ -287,7 +299,7 @@
|
|||||||
{#if skillChipRecord}
|
{#if skillChipRecord}
|
||||||
<div class="proposal-field-summary">
|
<div class="proposal-field-summary">
|
||||||
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.current)}</strong></div>{/each}
|
{#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>
|
||||||
<div class="proposal-structured-list">
|
<div class="proposal-structured-list">
|
||||||
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
|
<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>
|
<header><span>PROPOSED · SKILL CHIP</span><b>AFTER</b></header>
|
||||||
<div class="proposal-field-summary">
|
<div class="proposal-field-summary">
|
||||||
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.proposed)}</strong></div>{/each}
|
{#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>
|
||||||
<div class="proposal-structured-list">
|
<div class="proposal-structured-list">
|
||||||
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
|
<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]));
|
&& Math.floor(Number(active.split(':')[1]) / 3) === Number(cell.id.split(':')[1]));
|
||||||
|
|
||||||
const issueLabel = (issue: string) => {
|
const issueLabel = (issue: string) => {
|
||||||
if (/not configured|required/i.test(issue)) return 'ACTION REQUIRED';
|
if (/not configured|required|select|choose/i.test(issue)) return 'REQUIRED';
|
||||||
if (/incomplete/i.test(issue)) return 'DATA INCOMPLETE';
|
if (/incomplete/i.test(issue)) return 'INCOMPLETE';
|
||||||
return 'CHECK CONFIGURATION';
|
return 'CHECK';
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="loadout-matrix" aria-label="Loadout matrix">
|
<section class="loadout-matrix" aria-label="Loadout matrix">
|
||||||
<header>
|
<header>
|
||||||
<span>LOADOUT MATRIX</span>
|
<span>LOADOUT</span>
|
||||||
<b class:ready={configured}>{configured ? 'COMBAT READY' : 'CONFIGURATION INCOMPLETE'}</b>
|
<b class:ready={configured}>{configured ? 'COMPLETE' : 'INCOMPLETE'}</b>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="matrix-section doctrine-section">
|
<div class="matrix-section doctrine-section">
|
||||||
<small class="section-label">DOCTRINE · ROOT CHOICE</small>
|
<small class="section-label">SPECIALIZATION FOCUS</small>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="doctrine-cell"
|
class="doctrine-cell"
|
||||||
@@ -60,27 +60,15 @@
|
|||||||
onclick={() => onselect(doctrine.id)}
|
onclick={() => onselect(doctrine.id)}
|
||||||
>
|
>
|
||||||
<ItemIcon src={doctrine.iconUrl} alt="" className="matrix-icon" />
|
<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>
|
<i aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="supply-line"><span>SUPPLIES 3 ABILITIES</span></div>
|
<div class="supply-line"><span>ABILITIES</span></div>
|
||||||
<div class="ability-cells">
|
<div class="ability-cells">
|
||||||
{#each abilities as cell}
|
{#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)}>
|
<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" />
|
<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>
|
||||||
</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>
|
|
||||||
<i aria-hidden="true"></i>
|
<i aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -88,12 +76,25 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="matrix-section">
|
<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">
|
<div class="equipment-cells gear-cells">
|
||||||
{#each gear as cell}
|
{#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)}>
|
<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" />
|
<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>
|
<i aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -101,10 +102,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="matrix-section protocol-section">
|
<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)}>
|
<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" />
|
<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>
|
<i aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,21 +113,20 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.loadout-matrix{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}
|
.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)}
|
.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}
|
.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}
|
.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,.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}
|
.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}
|
: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: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}
|
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}
|
.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}
|
.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}
|
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) and (min-width:651px){.ability-cells{grid-template-columns:repeat(3,minmax(0,1fr))}.ability-cells button{min-height:68px}}
|
||||||
@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}.gear-cells{grid-template-columns:1fr}}
|
||||||
@media(max-width:520px){.ability-cells{grid-template-columns:1fr}.equipment-cells{grid-template-columns:1fr 1fr}}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -37,10 +37,9 @@
|
|||||||
<section class:page={layout === 'page'} class="loadout-known-stats" aria-labelledby={headingId}>
|
<section class:page={layout === 'page'} class="loadout-known-stats" aria-labelledby={headingId}>
|
||||||
<div class="summary-head">
|
<div class="summary-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow"><span></span> KNOWN EQUIPMENT TOTALS</p>
|
<p class="eyebrow"><span></span> KNOWN STATS</p>
|
||||||
<h2 id={headingId}>Agent stats</h2>
|
<h2 id={headingId}>Agent stats</h2>
|
||||||
</div>
|
</div>
|
||||||
<b>{summary.stats.length}</b>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-core">
|
<div class="summary-core">
|
||||||
{#each ['Firepower', 'Toughness', 'Engineering'] as name}
|
{#each ['Firepower', 'Toughness', 'Engineering'] as name}
|
||||||
@@ -53,49 +52,57 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="summary-scroll">
|
<div class="summary-scroll">
|
||||||
<div class="summary-section">
|
<div class="summary-section">
|
||||||
<header><span>COMBINED ATTRIBUTES</span><b>{summary.stats.length}</b></header>
|
<header><span>ATTRIBUTES</span><b>{summary.stats.length}</b></header>
|
||||||
{#each summary.stats as stat}
|
{#each summary.stats as stat}
|
||||||
<div class="summary-stat">
|
<div class="summary-stat">
|
||||||
<span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span>
|
<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>
|
<b>{unitValue(stat.value, stat.id)}</b>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p>Equip and configure items to calculate their known Attributes.</p>
|
<p>Equip items to see known Attributes.</p>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-section">
|
<div class="summary-section">
|
||||||
<header><span>ACTIVE SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>
|
<header><span>SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>
|
||||||
{#each summary.activeBonuses as bonus}
|
{#each summary.activeBonuses as bonus}
|
||||||
<div class="summary-bonus">
|
<div class="summary-bonus">
|
||||||
<span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span>
|
<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>
|
<em>+{unitValue(bonus.value, bonus.statId)} {attribute(bonus.statId)?.name}</em>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p>Matching Brand Sets and Skill Chips activate bonuses here.</p>
|
<p>No active bonuses.</p>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-section">
|
<div class="summary-section">
|
||||||
<header><span>EQUIPPED TALENTS</span><b>{summary.equippedTalents.length}</b></header>
|
<header><span>TALENTS</span><b>{summary.equippedTalents.length}</b></header>
|
||||||
{#each summary.equippedTalents as equipped}
|
{#each summary.equippedTalents as equipped}
|
||||||
<div class="summary-talent">
|
<div class="summary-talent">
|
||||||
<span>
|
<details open={layout === 'page'}>
|
||||||
<b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b>
|
<summary><b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b><small>{equipped.source}{equipped.tierLabel ? ` · ${equipped.tierLabel}` : ''}</small></summary>
|
||||||
<small>{equipped.source} · CONDITIONAL EFFECT NOT INCLUDED</small>
|
<p>{equipped.description}</p>
|
||||||
<em>{equipped.description}</em>
|
|
||||||
{#if equipped.id && talentCooldown(equipped.id)}<i class="talent-cooldown">{talentCooldown(equipped.id)}</i>{/if}
|
{#if equipped.id && talentCooldown(equipped.id)}<i class="talent-cooldown">{talentCooldown(equipped.id)}</i>{/if}
|
||||||
</span>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p>Selected equipment Talents appear here.</p>
|
<p>No Talents equipped.</p>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="known-stats-note">Totals reflect this saved loadout and the current canonical item definitions. They include fixed values, rolls, inherited profiles, enabled augments, set bonuses, Protocol effects, and static Specialization rank effects. Conditional Talent outcomes are reserved for the advanced combat model.</p>
|
<p class="known-stats-note">Known values only. Advanced effects are planned.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.loadout-known-stats{background:var(--shd-surface)}
|
.loadout-known-stats{background:var(--shd-surface)}
|
||||||
.summary-talent{grid-template-columns:1fr;cursor:default}
|
.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}
|
.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{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-scroll{max-height:none;display:grid;grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||||
|
|||||||
@@ -10,20 +10,20 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const capabilities = [
|
const capabilities = [
|
||||||
{ label: 'WEAPON OUTPUT', detail: 'Hit values, burst DPS, sustained DPS, and reload-aware firing cycles' },
|
{ label: 'WEAPON OUTPUT', detail: 'Hits and firing cycles' },
|
||||||
{ label: 'SKILL SIMULATION', detail: 'Ability cadence, target counts, critical hits, and multi-shot behavior' },
|
{ label: 'SKILL SIMULATION', detail: 'Timing and target caps' },
|
||||||
{ label: 'ENCOUNTER MODEL', detail: 'Duration, accuracy, movement, target flow, rotations, and downtime' },
|
{ label: 'ENCOUNTER MODEL', detail: 'Duration and rotations' },
|
||||||
{ label: 'CONDITIONAL EFFECTS', detail: 'Talent triggers, stack buildup, cooldowns, uptime, and resource changes' }
|
{ label: 'CONDITIONAL EFFECTS', detail: 'Triggers and uptime' }
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class:compact={variant === 'compact'} class="planned-combat-model" aria-labelledby={headingId}>
|
<section class:compact={variant === 'compact'} class="planned-combat-model" aria-labelledby={headingId}>
|
||||||
<header>
|
<header>
|
||||||
<div><span>ADVANCED COMBAT MODEL</span>{#if headingLevel === 'h2'}<h2 id={headingId}>DPS calculations</h2>{:else}<h3 id={headingId}>DPS calculations</h3>{/if}</div>
|
<div><span>COMBAT MODEL</span>{#if headingLevel === 'h2'}<h2 id={headingId}>DPS calculations</h2>{:else}<h3 id={headingId}>DPS calculations</h3>{/if}</div>
|
||||||
<b>COMING SOON</b>
|
<b>PLANNED</b>
|
||||||
</header>
|
</header>
|
||||||
{#if variant === 'full'}
|
{#if variant === 'full'}
|
||||||
<p>Known equipment totals remain available now. Advanced damage projections will return after their calculation rules and scenario controls are ready for player use.</p>
|
<p>Known stats are live. Damage projections are planned.</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="planned-capabilities" aria-label="Planned combat-model capabilities">
|
<div class="planned-capabilities" aria-label="Planned combat-model capabilities">
|
||||||
{#each capabilities as capability}
|
{#each capabilities as capability}
|
||||||
|
|||||||
@@ -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,
|
blankConditionalMechanicDraft,
|
||||||
currentHealthPercentBandPredicate,
|
currentHealthPercentBandPredicate,
|
||||||
eventFieldComparisonPredicate,
|
eventFieldComparisonPredicate,
|
||||||
|
mechanicTargetingForMode,
|
||||||
|
mechanicTargetingMode,
|
||||||
parseConditionalMechanicDraft,
|
parseConditionalMechanicDraft,
|
||||||
readCurrentHealthPercentBand,
|
readCurrentHealthPercentBand,
|
||||||
readEventFieldComparison,
|
readEventFieldComparison,
|
||||||
@@ -21,6 +23,7 @@
|
|||||||
type ConditionalMechanicPoolDraft,
|
type ConditionalMechanicPoolDraft,
|
||||||
type EditablePredicateScalar,
|
type EditablePredicateScalar,
|
||||||
type EventFieldComparison,
|
type EventFieldComparison,
|
||||||
|
type MechanicTargetingMode,
|
||||||
type PredicateComparisonOperator,
|
type PredicateComparisonOperator,
|
||||||
type StatePoolThresholdComparison
|
type StatePoolThresholdComparison
|
||||||
} from '$lib/calculations/mechanic-editor';
|
} from '$lib/calculations/mechanic-editor';
|
||||||
@@ -182,7 +185,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function eventPredicateOperatorOptions(clause: ConditionalMechanicClauseDraft) {
|
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
|
||||||
: predicateOperatorOptions.filter((option) => option.value === 'eq' || option.value === 'ne');
|
: predicateOperatorOptions.filter((option) => option.value === 'eq' || option.value === 'ne');
|
||||||
}
|
}
|
||||||
@@ -190,15 +195,21 @@
|
|||||||
function setClauseEvent(clauseIndex: number, event: string) {
|
function setClauseEvent(clauseIndex: number, event: string) {
|
||||||
const nextEvent = event as ConditionalMechanicClauseDraft['event'];
|
const nextEvent = event as ConditionalMechanicClauseDraft['event'];
|
||||||
const clause = rule.clauses[clauseIndex];
|
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 fields = registeredEventFields(nextEvent);
|
||||||
const comparison = readEventFieldComparison(clause.if);
|
const comparison = readEventFieldComparison(clause.if);
|
||||||
if (!comparison || !fields.length) {
|
if (!comparison || !fields.length) {
|
||||||
updateClause(clauseIndex, { event: nextEvent });
|
updateClause(clauseIndex, { event: nextEvent, outcomes });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const field = fields[0];
|
const field = fields[0];
|
||||||
updateClause(clauseIndex, {
|
updateClause(clauseIndex, {
|
||||||
event: nextEvent,
|
event: nextEvent,
|
||||||
|
outcomes,
|
||||||
if: eventFieldComparisonPredicate({
|
if: eventFieldComparisonPredicate({
|
||||||
path: field.path,
|
path: field.path,
|
||||||
operator: 'eq',
|
operator: 'eq',
|
||||||
@@ -403,6 +414,69 @@
|
|||||||
updateClause(clauseIndex, { outcomes });
|
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) {
|
function updateVariant(clauseIndex: number, outcomeIndex: number, variant: string, raw: string) {
|
||||||
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
||||||
const value = Number(raw);
|
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) {
|
function updateMutation(clauseIndex: number, mutationIndex: number, kind: 'add' | 'set' | 'consume' | 'clear', pool?: string, value?: number) {
|
||||||
const mutations = [...rule.clauses[clauseIndex].mutations];
|
const mutations = [...rule.clauses[clauseIndex].mutations];
|
||||||
const current = mutations[mutationIndex];
|
const current = mutations[mutationIndex];
|
||||||
@@ -981,6 +1099,20 @@
|
|||||||
{:else if conditionModel(clause) === 'advanced'}
|
{: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>
|
<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}
|
||||||
|
{#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>
|
||||||
<div class="reducer-controls">
|
<div class="reducer-controls">
|
||||||
<SegmentedChoice
|
<SegmentedChoice
|
||||||
@@ -1007,38 +1139,53 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mutation-panel">
|
<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}
|
{#each clause.mutations as mutation, mutationIndex}
|
||||||
<div class="mutation-row">
|
{#if mutation.kind === 'extend_duration'}
|
||||||
<SegmentedChoice
|
<div class="mutation-row ability-mutation-row">
|
||||||
legend="OPERATION"
|
<div class="branch-readout"><span>OPERATION</span><strong>EXTEND ASSIGNED ABILITY</strong></div>
|
||||||
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
|
<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>
|
||||||
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'clear',label:'CLEAR'}]}
|
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
|
||||||
value={mutation.kind === 'none' || mutation.kind === 'derive' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
|
</div>
|
||||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'clear')}
|
{:else if mutation.kind === 'start_cooldown_on_max'}
|
||||||
/>
|
<div class="mutation-row cooldown-mutation-row">
|
||||||
<SegmentedChoice
|
<div class="branch-readout"><span>OPERATION</span><strong>START COOLDOWN ON MAX</strong></div>
|
||||||
legend="STATE POOL"
|
<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]})}/>
|
||||||
name={`${idPrefix}-mutation-pool-${clauseIndex}-${mutationIndex}`}
|
<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]})}/>
|
||||||
options={rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))}
|
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
|
||||||
value={'pool' in mutation ? mutation.pool : rule.pools[0]?.key ?? ''}
|
</div>
|
||||||
columns={Math.min(4,Math.max(1,rule.pools.length))}
|
{:else}
|
||||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'clear' ? 'clear' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])}
|
<div class="mutation-row">
|
||||||
/>
|
|
||||||
{#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
|
<SegmentedChoice
|
||||||
legend="STATE OWNER"
|
legend="OPERATION"
|
||||||
name={`${idPrefix}-mutation-entity-${clauseIndex}-${mutationIndex}`}
|
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
|
||||||
options={[{value:'event_target',label:'EVENT TARGET'},{value:'event_actor',label:'EVENT ACTOR'}]}
|
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'clear',label:'CLEAR'}]}
|
||||||
value={'entity' in mutation && mutation.entity?.kind === 'event_actor' ? 'event_actor' : 'event_target'}
|
value={mutation.kind === 'none' || mutation.kind === 'derive' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
|
||||||
onchange={(values) => updateMutationEntity(clauseIndex,mutationIndex,values[0] as 'event_target'|'event_actor')}
|
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'clear')}
|
||||||
/>
|
/>
|
||||||
{/if}
|
<SegmentedChoice
|
||||||
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
|
legend="STATE POOL"
|
||||||
</div>
|
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}
|
{:else}
|
||||||
<p class="empty-state">This branch emits outcomes without changing shared state.</p>
|
<p class="empty-state">This branch emits outcomes without changing shared state.</p>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -1049,25 +1196,24 @@
|
|||||||
<div class="outcome-card">
|
<div class="outcome-card">
|
||||||
<div class="outcome-grid">
|
<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>
|
<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>
|
<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>
|
</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'}
|
{#if outcome.scope === 'allies' || outcome.scope === 'self_and_allies' || outcome.scope === 'target'}
|
||||||
<div class="delivery-controls">
|
<div class="delivery-controls">
|
||||||
<SegmentedChoice
|
<SegmentedChoice
|
||||||
legend="AREA TARGETING"
|
legend="AREA TARGETING"
|
||||||
name={`${idPrefix}-targeting-${clauseIndex}-${outcomeIndex}`}
|
name={`${idPrefix}-targeting-${clauseIndex}-${outcomeIndex}`}
|
||||||
options={[{value:'none',label:'NONE'},{value:'allies',label:'ALLIES IN RADIUS'},{value:'enemies',label:'ENEMIES IN RADIUS'}]}
|
options={targetingOptions(clause,outcome)}
|
||||||
value={outcome.targeting?.population ?? 'none'}
|
value={mechanicTargetingMode(outcome.targeting)}
|
||||||
onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{
|
onchange={(values) => setOutcomeTargeting(clauseIndex,outcomeIndex,values[0] as MechanicTargetingMode)}
|
||||||
targeting: values[0] === 'none'
|
|
||||||
? undefined
|
|
||||||
: {kind:'within_radius',population:values[0] as 'allies'|'enemies',radiusMeters:outcome.targeting?.radiusMeters ?? 20}
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
{#if outcome.targeting}
|
{#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>
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
<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}
|
{/each}
|
||||||
</div>
|
</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">
|
<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>
|
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{outcomes:clause.outcomes.filter((_,index) => index !== outcomeIndex)})}>REMOVE OUTCOME</button>
|
||||||
</div>
|
</div>
|
||||||
</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>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)}
|
.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)}
|
.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{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}
|
.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)}
|
.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)}
|
.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)}
|
.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-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)}
|
.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-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}
|
.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}
|
.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}
|
.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}
|
.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}
|
.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,.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)}
|
.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 DatabaseBadge = { label: string; tone?: string };
|
||||||
export type DatabaseMeter = { label: string; value: number };
|
export type DatabaseMeter = { label: string; value: number };
|
||||||
|
export type DatabaseWeaponModSlot = {
|
||||||
|
slot: string;
|
||||||
|
unlockedAtTierOne: boolean;
|
||||||
|
iconUrl?: string;
|
||||||
|
};
|
||||||
export type DatabaseFact = {
|
export type DatabaseFact = {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -26,8 +31,10 @@ export type DatabaseItem = {
|
|||||||
renderUrl?: string;
|
renderUrl?: string;
|
||||||
gameIcon?: GameIconName;
|
gameIcon?: GameIconName;
|
||||||
headerBadge?: string;
|
headerBadge?: string;
|
||||||
|
headerBadgeTone?: string;
|
||||||
headerBadges?: string[];
|
headerBadges?: string[];
|
||||||
headerMetaIcon?: GameIconName;
|
headerMetaIcon?: GameIconName;
|
||||||
|
topBorderTone?: string;
|
||||||
badges?: DatabaseBadge[];
|
badges?: DatabaseBadge[];
|
||||||
facts: DatabaseFact[];
|
facts: DatabaseFact[];
|
||||||
factLayout?: 'grid' | 'stack';
|
factLayout?: 'grid' | 'stack';
|
||||||
@@ -35,9 +42,17 @@ export type DatabaseItem = {
|
|||||||
lines?: DatabaseFact[];
|
lines?: DatabaseFact[];
|
||||||
afterLines?: DatabaseFact[];
|
afterLines?: DatabaseFact[];
|
||||||
iconGroups?: DatabaseIconGroup[];
|
iconGroups?: DatabaseIconGroup[];
|
||||||
|
weaponModSlots?: DatabaseWeaponModSlot[];
|
||||||
description?: string;
|
description?: string;
|
||||||
secondaryDescription?: string;
|
secondaryDescription?: string;
|
||||||
descriptionStyle?: 'prose' | 'data';
|
descriptionStyle?: 'prose' | 'data';
|
||||||
|
protocolPreview?: {
|
||||||
|
primaryStat: string;
|
||||||
|
primaryUnit: string;
|
||||||
|
primaryBaseValue: number;
|
||||||
|
talentDescription?: string;
|
||||||
|
talentEffects: Array<{ baseValue: number; unit: string }>;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DatabaseRelatedItem = {
|
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(input.canonicalCatalog.grammar.outcome.optional).toEqual(
|
||||||
expect.arrayContaining(['delivery', 'durationSeconds']),
|
expect.arrayContaining(['delivery', 'durationSeconds']),
|
||||||
);
|
);
|
||||||
@@ -136,7 +136,7 @@ describe('AI mechanic authoring prompt', () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
||||||
'Prompt contract version: 7',
|
'Prompt contract version: 10',
|
||||||
);
|
);
|
||||||
|
|
||||||
const advertised = structuredClone(
|
const advertised = structuredClone(
|
||||||
@@ -185,7 +185,7 @@ describe('AI mechanic authoring prompt', () => {
|
|||||||
expect(parsed.ok).toBe(true);
|
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(
|
const input = JSON.parse(
|
||||||
buildAiMechanicAuthoringInput({
|
buildAiMechanicAuthoringInput({
|
||||||
request,
|
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(
|
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
||||||
'Prompt contract version: 7',
|
'Prompt contract version: 10',
|
||||||
);
|
);
|
||||||
expect(input.canonicalCatalog.events).toContainEqual({
|
expect(input.canonicalCatalog.events).toContainEqual({
|
||||||
key: 'extra_health_changed',
|
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', () => {
|
it('advertises authoritative current Health percentage with strict band semantics', () => {
|
||||||
const input = JSON.parse(
|
const input = JSON.parse(
|
||||||
buildAiMechanicAuthoringInput({
|
buildAiMechanicAuthoringInput({
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES } from '$lib/calculations/mec
|
|||||||
import { MECHANIC_COMBAT_REFERENCE_REGISTRY } from '$lib/calculations/mechanic';
|
import { MECHANIC_COMBAT_REFERENCE_REGISTRY } from '$lib/calculations/mechanic';
|
||||||
import type { AiMechanicAttribute } from './mechanic-authoring-context';
|
import type { AiMechanicAttribute } from './mechanic-authoring-context';
|
||||||
|
|
||||||
export const AI_MECHANIC_PROMPT_VERSION = 7;
|
export const AI_MECHANIC_PROMPT_VERSION = 10;
|
||||||
|
|
||||||
const mechanicsGrammar = {
|
const mechanicsGrammar = {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -40,7 +40,14 @@ const mechanicsGrammar = {
|
|||||||
},
|
},
|
||||||
clause: {
|
clause: {
|
||||||
required: ['key', 'event', 'if', 'reducer', 'mutations', 'outcomes'],
|
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'],
|
predicateKinds: ['true', 'exists', 'compare', 'all', 'any', 'not'],
|
||||||
compareOperators: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'],
|
compareOperators: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'],
|
||||||
@@ -63,6 +70,8 @@ const mechanicsGrammar = {
|
|||||||
'derive',
|
'derive',
|
||||||
'clear',
|
'clear',
|
||||||
'transfer',
|
'transfer',
|
||||||
|
'extend_duration',
|
||||||
|
'start_cooldown_on_max',
|
||||||
],
|
],
|
||||||
entitySelectors: ['agent', 'event_actor', 'event_target', 'event_field'],
|
entitySelectors: ['agent', 'event_actor', 'event_target', 'event_field'],
|
||||||
valueReferences: [
|
valueReferences: [
|
||||||
@@ -70,6 +79,7 @@ const mechanicsGrammar = {
|
|||||||
'scenario',
|
'scenario',
|
||||||
'combat',
|
'combat',
|
||||||
'equipment',
|
'equipment',
|
||||||
|
'assigned_ability',
|
||||||
'attribute',
|
'attribute',
|
||||||
'reducer',
|
'reducer',
|
||||||
'pool',
|
'pool',
|
||||||
@@ -97,6 +107,7 @@ const mechanicsGrammar = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ ref: { source: 'equipment', path: 'protocolTalentUpgrades' } },
|
{ ref: { source: 'equipment', path: 'protocolTalentUpgrades' } },
|
||||||
|
{ ref: { source: 'assigned_ability', path: 'remainingDurationSeconds' } },
|
||||||
{
|
{
|
||||||
ref: {
|
ref: {
|
||||||
source: 'attribute',
|
source: 'attribute',
|
||||||
@@ -200,6 +211,16 @@ const mechanicsGrammar = {
|
|||||||
value: 'all',
|
value: 'all',
|
||||||
clearSource: true,
|
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,
|
outcome: CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES,
|
||||||
continuousRestorationOutcomeShape: {
|
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: {
|
preciseTraumaShape: {
|
||||||
version: 1,
|
version: 1,
|
||||||
simulationReadiness: 'ready',
|
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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'OS Protocol upgrade scaling may use equipment.protocolTalentUpgrades only when allowProtocolUpgradeScaling is true.',
|
||||||
'Assigned-ability scope may be used only when allowAssignedAbility 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 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.',
|
'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.',
|
'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());
|
beforeEach(() => resetAiMechanicRateLimitForTests());
|
||||||
|
|
||||||
it('allows only one in-flight request per account', () => {
|
it('allows only one in-flight request per account', () => {
|
||||||
const release = reserveAiMechanicGeneration('agent-1', 1_000);
|
const release = reserveAiMechanicGeneration('agent-1', false, 1_000);
|
||||||
expect(() => reserveAiMechanicGeneration('agent-1', 1_001)).toThrow(
|
expect(() => reserveAiMechanicGeneration('agent-1', false, 1_001)).toThrow(
|
||||||
AiMechanicRateLimitError,
|
AiMechanicRateLimitError,
|
||||||
);
|
);
|
||||||
release();
|
release();
|
||||||
expect(() => reserveAiMechanicGeneration('agent-1', 1_002)).not.toThrow();
|
expect(() =>
|
||||||
|
reserveAiMechanicGeneration('agent-1', false, 1_002),
|
||||||
|
).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('enforces the hourly account limit', () => {
|
it('enforces the hourly account limit', () => {
|
||||||
for (let request = 0; request < 10; request += 1) {
|
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();
|
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.',
|
'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
|
* duplicate submissions and basic abuse; OpenAI project budgets remain the hard
|
||||||
* cross-instance spending boundary.
|
* 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)) {
|
if (inFlightUsers.has(userId)) {
|
||||||
throw new AiMechanicRateLimitError(
|
throw new AiMechanicRateLimitError(
|
||||||
'A mechanic authoring request is already running for this account.',
|
'A mechanic authoring request is already running for this account.',
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export async function syncArticleTags(database: Database) {
|
|||||||
`specialization-${item.id}`,
|
`specialization-${item.id}`,
|
||||||
'specialization',
|
'specialization',
|
||||||
String(item.name),
|
String(item.name),
|
||||||
`specialization-${slugify(String(item.name))}`,
|
`specialization-${slugify(String(item.name))}-${item.id}`,
|
||||||
'specialization',
|
'specialization',
|
||||||
item.id,
|
item.id,
|
||||||
Number(item.active) ? 1 : 0,
|
Number(item.active) ? 1 : 0,
|
||||||
@@ -93,7 +93,7 @@ export async function syncArticleTags(database: Database) {
|
|||||||
`focus-${item.id}`,
|
`focus-${item.id}`,
|
||||||
'focus',
|
'focus',
|
||||||
name,
|
name,
|
||||||
`focus-${slugify(name)}`,
|
`focus-${slugify(name)}-${item.id}`,
|
||||||
'focus',
|
'focus',
|
||||||
item.id,
|
item.id,
|
||||||
Number(item.active) ? 1 : 0,
|
Number(item.active) ? 1 : 0,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export async function recordIsacAudit(input: {
|
|||||||
export async function auditRecord(
|
export async function auditRecord(
|
||||||
table: string,
|
table: string,
|
||||||
id: string,
|
id: string,
|
||||||
|
suppliedDatabase?: Database,
|
||||||
): Promise<Record<string, unknown> | null> {
|
): Promise<Record<string, unknown> | null> {
|
||||||
if (
|
if (
|
||||||
![
|
![
|
||||||
@@ -56,7 +57,7 @@ export async function auditRecord(
|
|||||||
) {
|
) {
|
||||||
throw new Error('Unsupported audit table');
|
throw new Error('Unsupported audit table');
|
||||||
}
|
}
|
||||||
const database = await getDatabase();
|
const database = suppliedDatabase ?? (await getDatabase());
|
||||||
return (
|
return (
|
||||||
(await (
|
(await (
|
||||||
await database.prepare(`SELECT * FROM ${table} WHERE id=?`)
|
await database.prepare(`SELECT * FROM ${table} WHERE id=?`)
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ describe('conditional mechanic persistence adapter', () => {
|
|||||||
value_scaling TEXT, unit TEXT, damage_basis TEXT, application_phase TEXT,
|
value_scaling TEXT, unit TEXT, damage_basis TEXT, application_phase TEXT,
|
||||||
combat_timing TEXT, resource_kind TEXT, delivery_mode 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,
|
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
|
description_slot INTEGER, sort_order INTEGER, created_at TEXT, updated_at TEXT
|
||||||
)`);
|
)`);
|
||||||
|
|
||||||
@@ -220,6 +221,8 @@ describe('conditional mechanic persistence adapter', () => {
|
|||||||
key: 'weapon-kill',
|
key: 'weapon-kill',
|
||||||
label: 'Weapon kill',
|
label: 'Weapon kill',
|
||||||
event: 'enemy_killed',
|
event: 'enemy_killed',
|
||||||
|
procChancePercent: 29,
|
||||||
|
requiresAssignedAbilityActive: true,
|
||||||
if: {
|
if: {
|
||||||
kind: 'compare',
|
kind: 'compare',
|
||||||
left: {
|
left: {
|
||||||
@@ -255,6 +258,7 @@ describe('conditional mechanic persistence adapter', () => {
|
|||||||
population: 'allies',
|
population: 'allies',
|
||||||
radiusMeters: 40,
|
radiusMeters: 40,
|
||||||
},
|
},
|
||||||
|
probabilityPercent: 29,
|
||||||
variants: {
|
variants: {
|
||||||
I: { value: 240 },
|
I: { value: 240 },
|
||||||
II: { value: 336 },
|
II: { value: 336 },
|
||||||
@@ -263,12 +267,18 @@ describe('conditional mechanic persistence adapter', () => {
|
|||||||
{
|
{
|
||||||
key: 'target-detonation',
|
key: 'target-detonation',
|
||||||
label: 'Target Detonation',
|
label: 'Target Detonation',
|
||||||
phase: 'on_exit',
|
phase: 'after_event',
|
||||||
kind: 'direct_damage',
|
kind: 'direct_damage',
|
||||||
scope: 'target',
|
scope: 'target',
|
||||||
basis: 'flat',
|
basis: 'flat',
|
||||||
statePool: 'marked-target',
|
statePool: 'marked-target',
|
||||||
perStack: true,
|
perStack: true,
|
||||||
|
targeting: {
|
||||||
|
kind: 'event_target_area',
|
||||||
|
center: 'event_target',
|
||||||
|
validTargets: 'enemies',
|
||||||
|
radiusMeters: 5,
|
||||||
|
},
|
||||||
variants: {
|
variants: {
|
||||||
I: {
|
I: {
|
||||||
value: 240,
|
value: 240,
|
||||||
@@ -322,6 +332,23 @@ describe('conditional mechanic persistence adapter', () => {
|
|||||||
mutations: [],
|
mutations: [],
|
||||||
outcomes: [],
|
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',
|
population: 'allies',
|
||||||
radiusMeters: 40,
|
radiusMeters: 40,
|
||||||
},
|
},
|
||||||
|
probabilityPercent: 29,
|
||||||
variants: {
|
variants: {
|
||||||
I: { value: 240 },
|
I: { value: 240 },
|
||||||
II: { value: 336 },
|
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({
|
expect(loaded?.clauses[0].if).toEqual({
|
||||||
kind: 'compare',
|
kind: 'compare',
|
||||||
left: {
|
left: {
|
||||||
@@ -381,6 +420,10 @@ describe('conditional mechanic persistence adapter', () => {
|
|||||||
operator: 'lt',
|
operator: 'lt',
|
||||||
right: 75,
|
right: 75,
|
||||||
});
|
});
|
||||||
|
expect(loaded?.clauses[0]).toMatchObject({
|
||||||
|
procChancePercent: 29,
|
||||||
|
requiresAssignedAbilityActive: true,
|
||||||
|
});
|
||||||
expect(loaded?.pools[0]).toMatchObject({
|
expect(loaded?.pools[0]).toMatchObject({
|
||||||
key: 'marked-target',
|
key: 'marked-target',
|
||||||
stateKind: 'target_mark',
|
stateKind: 'target_mark',
|
||||||
@@ -399,7 +442,7 @@ describe('conditional mechanic persistence adapter', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(loaded?.clauses[0].outcomes[1]).toMatchObject({
|
expect(loaded?.clauses[0].outcomes[1]).toMatchObject({
|
||||||
phase: 'on_exit',
|
phase: 'after_event',
|
||||||
kind: 'direct_damage',
|
kind: 'direct_damage',
|
||||||
scope: 'target',
|
scope: 'target',
|
||||||
statePool: 'marked-target',
|
statePool: 'marked-target',
|
||||||
@@ -430,5 +473,14 @@ describe('conditional mechanic persistence adapter', () => {
|
|||||||
right: true,
|
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_mode',
|
||||||
'delivery_duration_seconds',
|
'delivery_duration_seconds',
|
||||||
'targeting_json',
|
'targeting_json',
|
||||||
|
'probability_percent',
|
||||||
'state_pool_id',
|
'state_pool_id',
|
||||||
'min_state_value',
|
'min_state_value',
|
||||||
'max_state_value',
|
'max_state_value',
|
||||||
@@ -205,6 +206,10 @@ export function conditionalRuleDraftFromRows(
|
|||||||
.filter((row) => row.rule_id === rule.id && Number(row.active ?? 1) === 1)
|
.filter((row) => row.rule_id === rule.id && Number(row.active ?? 1) === 1)
|
||||||
.sort((left, right) => Number(left.sort_order) - Number(right.sort_order))
|
.sort((left, right) => Number(left.sort_order) - Number(right.sort_order))
|
||||||
.map((row): ConditionalMechanicClauseDraft => {
|
.map((row): ConditionalMechanicClauseDraft => {
|
||||||
|
const parameters = parseJson<Record<string, unknown>>(
|
||||||
|
row.recipe_parameters_json,
|
||||||
|
{},
|
||||||
|
);
|
||||||
const grouped = new Map<string, OutcomeRow[]>();
|
const grouped = new Map<string, OutcomeRow[]>();
|
||||||
for (const outcome of rowsByClause.get(String(row.id)) ?? []) {
|
for (const outcome of rowsByClause.get(String(row.id)) ?? []) {
|
||||||
const key = String(outcome.effect_key);
|
const key = String(outcome.effect_key);
|
||||||
@@ -268,6 +273,10 @@ export function conditionalRuleDraftFromRows(
|
|||||||
...(first.targeting_json
|
...(first.targeting_json
|
||||||
? { targeting: parseJson(first.targeting_json, undefined) }
|
? { targeting: parseJson(first.targeting_json, undefined) }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(first.probability_percent === null ||
|
||||||
|
first.probability_percent === undefined
|
||||||
|
? {}
|
||||||
|
: { probabilityPercent: Number(first.probability_percent) }),
|
||||||
...(first.stat_attribute_id
|
...(first.stat_attribute_id
|
||||||
? { attributeId: String(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
|
...(row.cooldown_seconds === null || row.cooldown_seconds === undefined
|
||||||
? {}
|
? {}
|
||||||
: { cooldownSeconds: Number(row.cooldown_seconds) }),
|
: { cooldownSeconds: Number(row.cooldown_seconds) }),
|
||||||
@@ -495,7 +513,17 @@ export async function saveConditionalRuleInTransaction(
|
|||||||
clause.label || clause.key,
|
clause.label || clause.key,
|
||||||
clause.event,
|
clause.event,
|
||||||
clause.recipeKey ?? null,
|
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.if ?? { kind: 'true' }),
|
||||||
JSON.stringify(clause.reducer),
|
JSON.stringify(clause.reducer),
|
||||||
clause.weight === undefined ? null : JSON.stringify(clause.weight),
|
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,
|
application_kind,effect_scope,stat_attribute_id,value,value_expression_json,
|
||||||
value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing,
|
value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing,
|
||||||
resource_kind,delivery_mode,delivery_duration_seconds,
|
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)
|
created_at,updated_at)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||||
).run(
|
).run(
|
||||||
randomUUID(),
|
randomUUID(),
|
||||||
ruleId,
|
ruleId,
|
||||||
@@ -552,6 +580,7 @@ export async function saveConditionalRuleInTransaction(
|
|||||||
outcome.targeting === undefined
|
outcome.targeting === undefined
|
||||||
? null
|
? null
|
||||||
: JSON.stringify(outcome.targeting),
|
: JSON.stringify(outcome.targeting),
|
||||||
|
outcome.probabilityPercent ?? null,
|
||||||
outcome.minStacks ?? null,
|
outcome.minStacks ?? null,
|
||||||
outcome.maxStacks ?? null,
|
outcome.maxStacks ?? null,
|
||||||
outcome.descriptionSlot ?? null,
|
outcome.descriptionSlot ?? null,
|
||||||
|
|||||||
@@ -37,14 +37,31 @@ const proposalResultKeys: Partial<
|
|||||||
saveAugmentProfile: { saved: 'augmentProfileSaved' },
|
saveAugmentProfile: { saved: 'augmentProfileSaved' },
|
||||||
},
|
},
|
||||||
weapons: { save: { saved: 'weaponSaved', mode: 'weaponMode' } },
|
weapons: { save: { saved: 'weaponSaved', mode: 'weaponMode' } },
|
||||||
weaponmods: { save: { saved: 'weaponModSaved', mode: 'weaponModMode' } },
|
weaponmods: {
|
||||||
gearmods: { save: { saved: 'gearModSaved', mode: 'gearModMode' } },
|
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' } },
|
talents: { saveTalent: { saved: 'talentSaved', mode: 'talentMode' } },
|
||||||
protocols: {
|
protocols: {
|
||||||
save: { saved: 'protocolSaved', mode: 'protocolMode' },
|
save: { saved: 'protocolSaved', mode: 'protocolMode' },
|
||||||
saveProfile: { saved: 'profileSaved' },
|
saveProfile: { saved: 'profileSaved' },
|
||||||
},
|
},
|
||||||
skillchips: { save: { saved: 'skillChipSaved', mode: 'skillChipMode' } },
|
skillchips: {
|
||||||
|
save: { saved: 'skillChipSaved', mode: 'skillChipMode' },
|
||||||
|
savePrimaryProfile: { saved: 'skillChipPrimaryProfileSaved' },
|
||||||
|
saveSecondaryPool: { saved: 'skillChipSecondaryPoolSaved' },
|
||||||
|
},
|
||||||
specializations: {
|
specializations: {
|
||||||
saveProgression: { saved: 'progressionSaved' },
|
saveProgression: { saved: 'progressionSaved' },
|
||||||
saveSpecialization: {
|
saveSpecialization: {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
BackgroundRemovalError,
|
BackgroundRemovalError,
|
||||||
removeBackgroundPreview,
|
removeBackgroundPreview,
|
||||||
} from './remove-background';
|
} from './remove-background';
|
||||||
|
import { weaponModSlotDefinitionById } from '$lib/weapons/mod-slots';
|
||||||
|
|
||||||
export const GAME_ICON_TYPES = [
|
export const GAME_ICON_TYPES = [
|
||||||
'talent',
|
'talent',
|
||||||
@@ -16,6 +17,7 @@ export const GAME_ICON_TYPES = [
|
|||||||
'specialization',
|
'specialization',
|
||||||
'specialization_ability',
|
'specialization_ability',
|
||||||
'specialization_node',
|
'specialization_node',
|
||||||
|
'weapon_mod_slot',
|
||||||
] as const;
|
] as const;
|
||||||
export type GameDataIconType = (typeof GAME_ICON_TYPES)[number];
|
export type GameDataIconType = (typeof GAME_ICON_TYPES)[number];
|
||||||
export const MAX_GAME_ICON_BYTES = 2 * 1024 * 1024;
|
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;
|
export const GAME_ICON_SIZE = 256;
|
||||||
const ENTITY_ID_PATTERN = /^[0-9a-f-]{36}$/i;
|
const ENTITY_ID_PATTERN = /^[0-9a-f-]{36}$/i;
|
||||||
const STORAGE_KEY_PATTERN =
|
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> = {
|
const LIBRARY_CATEGORY: Record<GameDataIconType, string> = {
|
||||||
talent: 'Talents',
|
talent: 'Talents',
|
||||||
brand_set: 'Brands',
|
brand_set: 'Brands',
|
||||||
@@ -32,6 +34,7 @@ const LIBRARY_CATEGORY: Record<GameDataIconType, string> = {
|
|||||||
specialization: 'Specializations',
|
specialization: 'Specializations',
|
||||||
specialization_ability: 'Specializations',
|
specialization_ability: 'Specializations',
|
||||||
specialization_node: 'Specializations',
|
specialization_node: 'Specializations',
|
||||||
|
weapon_mod_slot: 'Weapon Mods',
|
||||||
};
|
};
|
||||||
const LIBRARY_LABEL: Record<GameDataIconType, string> = {
|
const LIBRARY_LABEL: Record<GameDataIconType, string> = {
|
||||||
talent: 'Talent',
|
talent: 'Talent',
|
||||||
@@ -41,6 +44,7 @@ const LIBRARY_LABEL: Record<GameDataIconType, string> = {
|
|||||||
specialization: 'Specialization',
|
specialization: 'Specialization',
|
||||||
specialization_ability: 'Specialization Ability',
|
specialization_ability: 'Specialization Ability',
|
||||||
specialization_node: 'Specialization Tree Node',
|
specialization_node: 'Specialization Tree Node',
|
||||||
|
weapon_mod_slot: 'Weapon Mod Slot',
|
||||||
};
|
};
|
||||||
|
|
||||||
export class GameDataIconValidationError extends Error {}
|
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: [] };
|
return { name: null, tags: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import sharp from 'sharp';
|
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) => ({
|
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import('node:fs/promises')>()),
|
...(await importOriginal<typeof import('node:fs/promises')>()),
|
||||||
|
mkdir: mkdirMock,
|
||||||
rm: rmMock,
|
rm: rmMock,
|
||||||
|
writeFile: writeFileMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { GAME_ICON_TYPES } from './game-data-icons';
|
import { GAME_ICON_TYPES } from './game-data-icons';
|
||||||
import {
|
import {
|
||||||
GAME_DATA_RENDER_TYPES,
|
GAME_DATA_RENDER_TYPES,
|
||||||
ManagedImageDeletionError,
|
ManagedImageDeletionError,
|
||||||
|
applyGameDataRender,
|
||||||
deleteManagedImage,
|
deleteManagedImage,
|
||||||
processManagedImage,
|
processManagedImage,
|
||||||
} from './managed-images';
|
} from './managed-images';
|
||||||
@@ -26,6 +33,16 @@ class FakeDatabase {
|
|||||||
prepare(sql: string) {
|
prepare(sql: string) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
get: async (...args: unknown[]) => {
|
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'))
|
if (sql.startsWith('SELECT * FROM managed_images'))
|
||||||
return this.images.get(String(args[0]));
|
return this.images.get(String(args[0]));
|
||||||
if (sql.startsWith('SELECT * FROM game_data_icons')) {
|
if (sql.startsWith('SELECT * FROM game_data_icons')) {
|
||||||
@@ -42,7 +59,64 @@ class FakeDatabase {
|
|||||||
}
|
}
|
||||||
if (sql.startsWith('DELETE FROM managed_images')) {
|
if (sql.startsWith('DELETE FROM managed_images')) {
|
||||||
const row = this.images.get(String(args[0]));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
throw new Error(`Unexpected run: ${sql}`);
|
throw new Error(`Unexpected run: ${sql}`);
|
||||||
@@ -210,3 +284,94 @@ describe('processManagedImage', () => {
|
|||||||
expect(metadata.height).toBe(1000);
|
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 MAX_PIXELS = 24_000_000;
|
||||||
const ID_PATTERN = /^[0-9a-f-]{36}$/i;
|
const ID_PATTERN = /^[0-9a-f-]{36}$/i;
|
||||||
const KEY_PATTERN =
|
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 ManagedImageValidationError extends Error {}
|
||||||
export class ManagedImageDeletionError extends Error {}
|
export class ManagedImageDeletionError extends Error {}
|
||||||
@@ -250,7 +250,7 @@ export async function applyGameDataRender(
|
|||||||
);
|
);
|
||||||
await (
|
await (
|
||||||
await database.prepare(`UPDATE managed_images SET category=?,tags_json=?,is_icon=0,force_white=0,
|
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(
|
).run(
|
||||||
category[type],
|
category[type],
|
||||||
tags,
|
tags,
|
||||||
@@ -258,19 +258,14 @@ export async function applyGameDataRender(
|
|||||||
processed.output.length,
|
processed.output.length,
|
||||||
processed.width,
|
processed.width,
|
||||||
processed.height,
|
processed.height,
|
||||||
actorId,
|
|
||||||
now,
|
now,
|
||||||
existing.id,
|
existing.id,
|
||||||
);
|
);
|
||||||
return {
|
return (
|
||||||
...existing,
|
(await (
|
||||||
category: category[type],
|
await database.prepare('SELECT * FROM managed_images WHERE id=?')
|
||||||
tags_json: tags,
|
).get(existing.id)) ?? null
|
||||||
byte_size: processed.output.length,
|
);
|
||||||
width: processed.width,
|
|
||||||
height: processed.height,
|
|
||||||
updated_at: now,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const storageKey = await saveManagedImageFile(id, processed.output);
|
const storageKey = await saveManagedImageFile(id, processed.output);
|
||||||
@@ -298,20 +293,11 @@ export async function applyGameDataRender(
|
|||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
return {
|
return (
|
||||||
id,
|
(await (
|
||||||
name,
|
await database.prepare('SELECT * FROM managed_images WHERE id=?')
|
||||||
category: category[type],
|
).get(id)) ?? null
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteManagedImage(database: Database, id: string) {
|
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
|
||||||
|
>;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Database } from '@tursodatabase/database';
|
import type { Database } from '@tursodatabase/database';
|
||||||
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
|
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
|
||||||
|
import { hydrateSkillChipProfileRows } from '$lib/skill-chips/profiles';
|
||||||
|
|
||||||
export async function loadSharedBuildSummaryCatalog(database: Database) {
|
export async function loadSharedBuildSummaryCatalog(database: Database) {
|
||||||
const [
|
const [
|
||||||
@@ -15,7 +16,12 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
|||||||
talentEffects,
|
talentEffects,
|
||||||
talentTemplates,
|
talentTemplates,
|
||||||
chipSets,
|
chipSets,
|
||||||
|
abilitySkillChipLinks,
|
||||||
chipTwoPiece,
|
chipTwoPiece,
|
||||||
|
chipPrimaryOptions,
|
||||||
|
chipPrimaryQualityValues,
|
||||||
|
chipSecondaryOptions,
|
||||||
|
chipSecondaryQualityValues,
|
||||||
chipTalents,
|
chipTalents,
|
||||||
chipTalentEffects,
|
chipTalentEffects,
|
||||||
protocols,
|
protocols,
|
||||||
@@ -26,6 +32,8 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
|||||||
protocolTalentEffects,
|
protocolTalentEffects,
|
||||||
specializations,
|
specializations,
|
||||||
specializationFocuses,
|
specializationFocuses,
|
||||||
|
specializationAbilities,
|
||||||
|
focusAbilities,
|
||||||
specializationNodes,
|
specializationNodes,
|
||||||
specializationRanks,
|
specializationRanks,
|
||||||
specializationRankEffects,
|
specializationRankEffects,
|
||||||
@@ -80,9 +88,29 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
|||||||
'SELECT * FROM skill_chip_sets ORDER BY name COLLATE NOCASE',
|
'SELECT * FROM skill_chip_sets ORDER BY name COLLATE NOCASE',
|
||||||
)
|
)
|
||||||
).all(),
|
).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')
|
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
|
||||||
).all(),
|
).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_talents')).all(),
|
||||||
(
|
(
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
@@ -119,6 +147,16 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
|||||||
'SELECT * FROM specialization_focuses ORDER BY specialization_id,display_order,name COLLATE NOCASE',
|
'SELECT * FROM specialization_focuses ORDER BY specialization_id,display_order,name COLLATE NOCASE',
|
||||||
)
|
)
|
||||||
).all(),
|
).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(
|
await database.prepare(
|
||||||
'SELECT * FROM specialization_tree_nodes ORDER BY focus_id,row_number,column_number',
|
'SELECT * FROM specialization_tree_nodes ORDER BY focus_id,row_number,column_number',
|
||||||
@@ -161,12 +199,32 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
|||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
|
skillChipProfile: {
|
||||||
|
primaryOptions: hydrateSkillChipProfileRows(
|
||||||
|
chipPrimaryOptions,
|
||||||
|
chipPrimaryQualityValues,
|
||||||
|
),
|
||||||
|
secondaryOptions: hydrateSkillChipProfileRows(
|
||||||
|
chipSecondaryOptions,
|
||||||
|
chipSecondaryQualityValues,
|
||||||
|
),
|
||||||
|
},
|
||||||
chipSets: chipSets.map((set) => {
|
chipSets: chipSets.map((set) => {
|
||||||
const talent = chipTalents.find(
|
const talent = chipTalents.find(
|
||||||
(item) => item.skill_chip_set_id === set.id,
|
(item) => item.skill_chip_set_id === set.id,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...set,
|
...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:
|
twoPiece:
|
||||||
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
|
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
|
||||||
null,
|
null,
|
||||||
@@ -215,6 +273,18 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
|||||||
.filter((focus) => focus.specialization_id === specialization.id)
|
.filter((focus) => focus.specialization_id === specialization.id)
|
||||||
.map((focus) => ({
|
.map((focus) => ({
|
||||||
...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
|
nodes: specializationNodes
|
||||||
.filter((node) => node.focus_id === focus.id)
|
.filter((node) => node.focus_id === focus.id)
|
||||||
.map((node) => ({
|
.map((node) => ({
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export async function loadSharedBuildProjection(
|
|||||||
).all(),
|
).all(),
|
||||||
(
|
(
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
'SELECT id,name,category,is_exotic,rpm,magazine,reload_speed,damage_type FROM weapons',
|
'SELECT id,name,category,is_exotic,rpm,magazine,reload_speed,damage_type,exotic_talent_id FROM weapons',
|
||||||
)
|
)
|
||||||
).all(),
|
).all(),
|
||||||
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
|
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
|
||||||
|
|||||||
@@ -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,
|
abilityCount: 0,
|
||||||
chipReadyCount: 0,
|
chipReadyCount: 0,
|
||||||
};
|
};
|
||||||
const [config, abilityCountRow, chipReadyRow, nodes, rankProfiles] =
|
const [
|
||||||
await Promise.all([
|
config,
|
||||||
(
|
abilityCountRow,
|
||||||
await database.prepare(
|
profileRow,
|
||||||
"SELECT xp_points,shd_cache_points FROM specialization_progression_config WHERE id='default'",
|
chipReadyRow,
|
||||||
)
|
nodes,
|
||||||
).get(),
|
rankProfiles,
|
||||||
(
|
] = await Promise.all([
|
||||||
await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
|
(
|
||||||
|
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
|
JOIN specialization_abilities ability ON ability.id=link.ability_id
|
||||||
WHERE link.focus_id=? AND ability.active=1`)
|
WHERE link.focus_id=? AND ability.active=1`)
|
||||||
).get(focusId),
|
).get(focusId),
|
||||||
(
|
(
|
||||||
await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
|
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
|
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
|
||||||
WHERE link.focus_id=? AND (
|
WHERE link.focus_id=? AND (
|
||||||
SELECT COUNT(*) FROM skill_chip_sets chips
|
SELECT COUNT(*) FROM skill_chip_sets chips
|
||||||
WHERE chips.specialization_ability_id=ability.id AND chips.active=1
|
WHERE chips.specialization_ability_id=ability.id AND chips.active=1
|
||||||
)=3`)
|
)=3`)
|
||||||
).get(focusId),
|
).get(focusId),
|
||||||
(
|
(
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
'SELECT row_number,max_rank FROM specialization_tree_nodes WHERE focus_id=? AND active=1 ORDER BY row_number,column_number',
|
'SELECT row_number,max_rank FROM specialization_tree_nodes WHERE focus_id=? AND active=1 ORDER BY row_number,column_number',
|
||||||
)
|
)
|
||||||
).all(focusId),
|
).all(focusId),
|
||||||
(
|
(
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
'SELECT rank,point_cost FROM specialization_rank_cost_profiles ORDER BY rank',
|
'SELECT rank,point_cost FROM specialization_rank_cost_profiles ORDER BY rank',
|
||||||
)
|
)
|
||||||
).all(),
|
).all(),
|
||||||
]);
|
]);
|
||||||
const pointForRank = new Map(
|
const pointForRank = new Map(
|
||||||
rankProfiles.map((profile) => [
|
rankProfiles.map((profile) => [
|
||||||
Number(profile.rank),
|
Number(profile.rank),
|
||||||
@@ -69,6 +83,7 @@ export async function refreshSpecializationFocusReady(
|
|||||||
Number(focus.active) === 1 &&
|
Number(focus.active) === 1 &&
|
||||||
abilityCount === 3 &&
|
abilityCount === 3 &&
|
||||||
chipReadyCount === 3 &&
|
chipReadyCount === 3 &&
|
||||||
|
Number(profileRow?.ready) === 1 &&
|
||||||
nodes.length > 0 &&
|
nodes.length > 0 &&
|
||||||
contiguousRows &&
|
contiguousRows &&
|
||||||
pointTotal === availablePoints;
|
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 { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
WEAPON_MOD_SLOT_DEFINITIONS,
|
||||||
|
WEAPON_MOD_SLOT_TYPES,
|
||||||
excludedTierZeroWeaponModSlot,
|
excludedTierZeroWeaponModSlot,
|
||||||
expandSharedWeaponModSlots,
|
expandSharedWeaponModSlots,
|
||||||
normalizeWeaponModSlotPayload,
|
normalizeWeaponModSlotPayload,
|
||||||
|
publicWeaponModSlots,
|
||||||
sharedWeaponModSlots,
|
sharedWeaponModSlots,
|
||||||
validateWeaponModSlots,
|
validateWeaponModSlots,
|
||||||
|
weaponModSlotDefinitionById,
|
||||||
|
weaponModSlotDefinitionByName,
|
||||||
weaponModSlotsComplete,
|
weaponModSlotsComplete,
|
||||||
type WeaponModSlotConfig,
|
type WeaponModSlotConfig,
|
||||||
} from './mod-slots';
|
} from './mod-slots';
|
||||||
@@ -15,6 +20,23 @@ const configuration = (tier: number, slots: string[]): WeaponModSlotConfig => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('weapon mod slot configuration', () => {
|
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', () => {
|
it('accepts missing and partial slot data for later configuration', () => {
|
||||||
const slots = [
|
const slots = [
|
||||||
configuration(0, []),
|
configuration(0, []),
|
||||||
@@ -99,4 +121,31 @@ describe('weapon mod slot configuration', () => {
|
|||||||
]),
|
]),
|
||||||
).toEqual([configuration(1, ['Optics', 'Muzzle', 'Barrel'])]);
|
).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 = [
|
export const WEAPON_MOD_SLOT_DEFINITIONS = [
|
||||||
'Optics',
|
{ id: '28ffc3b1-5d98-4273-a9af-67dbff3e5c76', name: 'Optics' },
|
||||||
'Muzzle',
|
{ id: 'c4598f51-2cb0-4a20-a3a7-6d8983a26598', name: 'Muzzle' },
|
||||||
'Underbarrel',
|
{ id: '85ca3493-1871-4d22-950a-ed718fd547e0', name: 'Underbarrel' },
|
||||||
'Magazine',
|
{ id: '4d809a35-8285-4bff-be98-a0effe1d257e', name: 'Magazine' },
|
||||||
'Barrel',
|
{ id: '0e157cfe-975c-441d-8f49-72983161e7b1', name: 'Barrel' },
|
||||||
'Upper Receiver',
|
{ id: '551fd7c8-3e5b-4c89-8437-9b901e89e8aa', name: 'Upper Receiver' },
|
||||||
'Lower Receiver',
|
{ id: '72573367-6e91-4312-9cc3-710ede4f078e', name: 'Lower Receiver' },
|
||||||
'Trigger',
|
{ id: '8dda5edb-6079-4a23-a556-cd9be4521c70', name: 'Trigger' },
|
||||||
] as const;
|
] 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 = {
|
export type WeaponModSlotConfig = {
|
||||||
tier: number;
|
tier: number;
|
||||||
slots: string[];
|
slots: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PublicWeaponModSlot = {
|
||||||
|
slot: string;
|
||||||
|
unlockedAtTierOne: boolean;
|
||||||
|
iconUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function normalizeWeaponModSlotPayload(
|
export function normalizeWeaponModSlotPayload(
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
): WeaponModSlotConfig[] {
|
): 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(
|
export function excludedTierZeroWeaponModSlot(
|
||||||
sharedSlots: string[],
|
sharedSlots: string[],
|
||||||
configurations: WeaponModSlotConfig[],
|
configurations: WeaponModSlotConfig[],
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => {
|
|||||||
WHERE users.id=?`)
|
WHERE users.id=?`)
|
||||||
).get(agentId);
|
).get(agentId);
|
||||||
const isSelf = viewer?.id === agentId;
|
const isSelf = viewer?.id === agentId;
|
||||||
|
const canManageNews =
|
||||||
|
isSelf &&
|
||||||
|
(viewerPermissions.includes('*') ||
|
||||||
|
viewerPermissions.includes('news.manage'));
|
||||||
if (unavailable(agent) || (!Boolean(agent?.is_public) && !isSelf))
|
if (unavailable(agent) || (!Boolean(agent?.is_public) && !isSelf))
|
||||||
error(404, 'Agent profile not found');
|
error(404, 'Agent profile not found');
|
||||||
if (params.id !== String(agent.public_slug)) {
|
if (params.id !== String(agent.public_slug)) {
|
||||||
@@ -129,6 +133,9 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => {
|
|||||||
profileCommentCount,
|
profileCommentCount,
|
||||||
buildCount,
|
buildCount,
|
||||||
guideCount,
|
guideCount,
|
||||||
|
ownedBuildRows,
|
||||||
|
ownedGuideRows,
|
||||||
|
ownedNewsRows,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
(
|
(
|
||||||
await database.prepare(`SELECT roles.name FROM user_roles
|
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
|
await database.prepare(`SELECT COUNT(*) AS count FROM articles
|
||||||
WHERE kind='guide' AND owner_id=? AND visibility='public' AND deleted_at IS NULL`)
|
WHERE kind='guide' AND owner_id=? AND visibility='public' AND deleted_at IS NULL`)
|
||||||
).get(agentId),
|
).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(
|
const loadedCommentIds = new Set(
|
||||||
@@ -401,6 +430,32 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => {
|
|||||||
title: String(guide.title),
|
title: String(guide.title),
|
||||||
updatedAt: String(guide.updated_at),
|
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) => {
|
authoredComments: authoredCommentRows.map((comment) => {
|
||||||
const targetType = String(comment.target_type);
|
const targetType = String(comment.target_type);
|
||||||
const targetId = String(comment.target_id);
|
const targetId = String(comment.target_id);
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
title: string;
|
title: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
type OwnedContent = PublicContent & { visibility: string };
|
||||||
type AuthoredComment = {
|
type AuthoredComment = {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -60,6 +61,12 @@
|
|||||||
counts: { builds: number; guides: number; authoredComments: number; profileComments: number };
|
counts: { builds: number; guides: number; authoredComments: number; profileComments: number };
|
||||||
builds: PublicContent[];
|
builds: PublicContent[];
|
||||||
guides: PublicContent[];
|
guides: PublicContent[];
|
||||||
|
ownedContent: {
|
||||||
|
builds: OwnedContent[];
|
||||||
|
guides: OwnedContent[];
|
||||||
|
news: OwnedContent[];
|
||||||
|
canManageNews: boolean;
|
||||||
|
} | null;
|
||||||
authoredComments: AuthoredComment[];
|
authoredComments: AuthoredComment[];
|
||||||
profileComments: ProfileComment[];
|
profileComments: ProfileComment[];
|
||||||
viewer: { id: string | null; canComment: boolean; canReportProfile: boolean; isSelf: boolean };
|
viewer: { id: string | null; canComment: boolean; canReportProfile: boolean; isSelf: boolean };
|
||||||
@@ -86,6 +93,7 @@
|
|||||||
? 'DATE UNKNOWN'
|
? 'DATE UNKNOWN'
|
||||||
: date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }).toUpperCase();
|
: date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }).toUpperCase();
|
||||||
};
|
};
|
||||||
|
const visibilityLabel = (visibility: string) => visibility.toUpperCase().replace('_', ' ');
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const token = String(form?.profileCommentResultToken ?? form?.commentResultToken ?? '');
|
const token = String(form?.profileCommentResultToken ?? form?.commentResultToken ?? '');
|
||||||
@@ -242,6 +250,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<section class="profile-comms" id="profile-comments">
|
<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}
|
.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}
|
.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)}
|
.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}
|
.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}
|
.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}
|
.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}
|
.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}
|
.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){.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(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}}
|
@media(prefers-reduced-motion:reduce){.public-content-card,.profile-comment-trigger,.comment-reply{transition:none}}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -60,7 +60,10 @@ export const POST: RequestHandler = async ({ cookies, request, url }) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const authoringRequest = parseAiCalculationAuthoringRequest(payload);
|
const authoringRequest = parseAiCalculationAuthoringRequest(payload);
|
||||||
const release = reserveAiMechanicGeneration(operator.id);
|
const release = reserveAiMechanicGeneration(
|
||||||
|
operator.id,
|
||||||
|
operator.role === 'admin',
|
||||||
|
);
|
||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
response = await generateAiCalculationDraft(
|
response = await generateAiCalculationDraft(
|
||||||
|
|||||||
@@ -58,7 +58,10 @@ export const POST: RequestHandler = async ({ cookies, request, url }) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const authoringRequest = parseAiMechanicAuthoringRequest(payload);
|
const authoringRequest = parseAiMechanicAuthoringRequest(payload);
|
||||||
const release = reserveAiMechanicGeneration(operator.id);
|
const release = reserveAiMechanicGeneration(
|
||||||
|
operator.id,
|
||||||
|
operator.role === 'admin',
|
||||||
|
);
|
||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
response = await generateAiMechanicDraft(authoringRequest, operator.id);
|
response = await generateAiMechanicDraft(authoringRequest, operator.id);
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { gameDataIconUrl } from '$lib/server/game-data-icons';
|
|||||||
import { getSessionUser } from '$lib/server/auth/session';
|
import { getSessionUser } from '$lib/server/auth/session';
|
||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { randomUUID } from 'node:crypto';
|
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 { publicOrigin } from '$lib/server/public-origin';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
|
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
|
||||||
|
import { hydrateSkillChipProfileRows } from '$lib/skill-chips/profiles';
|
||||||
|
|
||||||
const validVisibility = new Set(['private', 'unlisted', 'public']);
|
const validVisibility = new Set(['private', 'unlisted', 'public']);
|
||||||
const slugify = (value: string) =>
|
const slugify = (value: string) =>
|
||||||
@@ -28,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 }) => {
|
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const viewer = await getSessionUser(cookies);
|
const viewer = await getSessionUser(cookies);
|
||||||
@@ -69,9 +260,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
talentEffects,
|
talentEffects,
|
||||||
talentTemplates,
|
talentTemplates,
|
||||||
chipSets,
|
chipSets,
|
||||||
|
abilitySkillChipLinks,
|
||||||
chipTwoPiece,
|
chipTwoPiece,
|
||||||
chipPrimary,
|
chipPrimaryOptions,
|
||||||
chipSecondary,
|
chipPrimaryQualityValues,
|
||||||
|
chipSecondaryOptions,
|
||||||
|
chipSecondaryQualityValues,
|
||||||
chipTalents,
|
chipTalents,
|
||||||
chipTalentEffects,
|
chipTalentEffects,
|
||||||
protocols,
|
protocols,
|
||||||
@@ -159,12 +353,36 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
'SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
|
'SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||||
)
|
)
|
||||||
).all(),
|
).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')
|
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
|
||||||
).all(),
|
).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(),
|
).all(),
|
||||||
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
|
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
|
||||||
(
|
(
|
||||||
@@ -329,22 +547,40 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
weaponBonuses,
|
weaponBonuses,
|
||||||
),
|
),
|
||||||
talents: configuredTalents,
|
talents: configuredTalents,
|
||||||
|
skillChipProfile: {
|
||||||
|
primaryOptions: hydrateSkillChipProfileRows(
|
||||||
|
chipPrimaryOptions,
|
||||||
|
chipPrimaryQualityValues,
|
||||||
|
),
|
||||||
|
secondaryOptions: hydrateSkillChipProfileRows(
|
||||||
|
chipSecondaryOptions,
|
||||||
|
chipSecondaryQualityValues,
|
||||||
|
),
|
||||||
|
},
|
||||||
chipSets: chipSets.map((set) => {
|
chipSets: chipSets.map((set) => {
|
||||||
const talent = chipTalents.find(
|
const talent = chipTalents.find(
|
||||||
(item) => item.skill_chip_set_id === set.id,
|
(item) => item.skill_chip_set_id === set.id,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...set,
|
...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),
|
iconUrl: iconUrl('skill_chip', set.id),
|
||||||
twoPiece:
|
twoPiece:
|
||||||
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
|
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
|
||||||
null,
|
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: talent
|
||||||
? {
|
? {
|
||||||
...talent,
|
...talent,
|
||||||
@@ -473,6 +709,14 @@ export const actions: Actions = {
|
|||||||
return fail(400, { buildError: 'Choose a valid build visibility.' });
|
return fail(400, { buildError: 'Choose a valid build visibility.' });
|
||||||
|
|
||||||
const database = await getDatabase();
|
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();
|
const now = new Date().toISOString();
|
||||||
let buildId = id;
|
let buildId = id;
|
||||||
let slug = '';
|
let slug = '';
|
||||||
|
|||||||
+262
-123
@@ -11,12 +11,15 @@
|
|||||||
import BuildShareControl from '$lib/components/BuildShareControl.svelte';
|
import BuildShareControl from '$lib/components/BuildShareControl.svelte';
|
||||||
import LoadoutMatrix, { type MatrixCell } from '$lib/components/builder/LoadoutMatrix.svelte';
|
import LoadoutMatrix, { type MatrixCell } from '$lib/components/builder/LoadoutMatrix.svelte';
|
||||||
import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte';
|
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 PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte';
|
||||||
|
import TalentTierChoice from '$lib/components/builder/TalentTierChoice.svelte';
|
||||||
import { summarizeLoadout } from '$lib/builder/loadout-summary';
|
import { summarizeLoadout } from '$lib/builder/loadout-summary';
|
||||||
|
import { calculateWeaponBaseline } from '$lib/builder/weapon-baseline';
|
||||||
import { toast } from '$lib/toast';
|
import { toast } from '$lib/toast';
|
||||||
import {
|
import {
|
||||||
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, weaponBaseRange,
|
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, migrateLoadoutDraftV5, parseLoadoutDraft, remapRangeValue, scaled, skillChipQualities, skillChipSecondaryCapacity, weaponBaseRange,
|
||||||
type LoadoutDraft, type Roll
|
type LoadoutDraft, type Roll, type SkillChipQuality, type TalentTier
|
||||||
} from '$lib/builder/loadout';
|
} from '$lib/builder/loadout';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
@@ -35,11 +38,29 @@
|
|||||||
const coreKeys = ['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 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 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 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 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 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 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 protocolItem = () => data.protocols.find((item) => item.id === draft.protocol.itemId);
|
||||||
const allFocuses = $derived(data.specializations.flatMap((specialization: any) =>
|
const allFocuses = $derived(data.specializations.flatMap((specialization: any) =>
|
||||||
specialization.focuses.map((focus: any) => ({ ...focus, specialization }))
|
specialization.focuses.map((focus: any) => ({ ...focus, specialization }))
|
||||||
@@ -56,6 +77,8 @@
|
|||||||
return selectedRank ? [{ node, rank: node.ranks.find((entry: any) => Number(entry.rank) === selectedRank) }] : [];
|
return selectedRank ? [{ node, rank: node.ranks.find((entry: any) => Number(entry.rank) === selectedRank) }] : [];
|
||||||
}) ?? []);
|
}) ?? []);
|
||||||
const talent = (id: string) => data.talents.find((item) => item.id === id);
|
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 brand = (id: string) => data.brands.find((item) => item.id === id);
|
||||||
const unitValue = (value: unknown, statId: string) => {
|
const unitValue = (value: unknown, statId: string) => {
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
@@ -80,13 +103,36 @@
|
|||||||
coreKeys.reduce((total, key) => total + Number(selection[key] ?? 0), 0);
|
coreKeys.reduce((total, key) => total + Number(selection[key] ?? 0), 0);
|
||||||
const attributeRange = (stat: any, source: 'gear' | 'weapon', level: number) =>
|
const attributeRange = (stat: any, source: 'gear' | 'weapon', level: number) =>
|
||||||
scaledRange(stat?.[`${source}_min`], stat?.[`${source}_max`], level, stat?.[`${source}_reference_level`], source);
|
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;
|
roll.statId = id;
|
||||||
const stat = attribute(id);
|
const stat = attribute(id);
|
||||||
const min = source === 'chip'
|
roll.value = Number(attributeRange(stat, source, level).min ?? 0);
|
||||||
? stat?.skill_chip_min
|
};
|
||||||
: attributeRange(stat, source, level).min;
|
const setChipRoll = (roll: Roll, id: string, kind: 'primary' | 'secondary', quality: SkillChipQuality) => {
|
||||||
roll.value = Number(min ?? 0);
|
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 rankPointCost = (rank: number) => Number(data.specializationProgression.rankProfiles.find((profile: any) => Number(profile.rank) === rank)?.point_cost ?? rank);
|
||||||
const selectedSpecializationPoints = $derived(selectedFocus?.nodes.reduce((total: number, node: any) => {
|
const selectedSpecializationPoints = $derived(selectedFocus?.nodes.reduce((total: number, node: any) => {
|
||||||
@@ -131,14 +177,14 @@
|
|||||||
const chip = draft.chips[index];
|
const chip = draft.chips[index];
|
||||||
const ability = next.abilities[Math.floor(index / 3)];
|
const ability = next.abilities[Math.floor(index / 3)];
|
||||||
const definition = data.chipSets.find((item: any) => item.id === chip.setId);
|
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));
|
displaced.push(structuredClone(chip));
|
||||||
draft.chips[index] = createDraft().chips[index];
|
draft.chips[index] = createDraft().chips[index];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const chip of displaced) {
|
for (const chip of displaced) {
|
||||||
const definition = data.chipSets.find((item: any) => item.id === chip.setId);
|
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;
|
if (abilityIndex < 0) continue;
|
||||||
const targetIndex = [0, 1, 2].map((slot) => abilityIndex * 3 + slot).find((slot) => !draft.chips[slot].setId);
|
const targetIndex = [0, 1, 2].map((slot) => abilityIndex * 3 + slot).find((slot) => !draft.chips[slot].setId);
|
||||||
if (targetIndex === undefined) continue;
|
if (targetIndex === undefined) continue;
|
||||||
@@ -170,6 +216,7 @@
|
|||||||
selection.level = 50;
|
selection.level = 50;
|
||||||
selection.brandId = item?.brandIds[0] ?? '';
|
selection.brandId = item?.brandIds[0] ?? '';
|
||||||
selection.talentId = item?.talentIds[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.health = Number(item?.base_health_min ?? 0);
|
||||||
selection.armor = Number(item?.base_armor_min ?? 0);
|
selection.armor = Number(item?.base_armor_min ?? 0);
|
||||||
const core = coreAllocationRange(Number(item?.tier ?? 0), selection.level, 'gear');
|
const core = coreAllocationRange(Number(item?.tier ?? 0), selection.level, 'gear');
|
||||||
@@ -218,19 +265,48 @@
|
|||||||
const core = coreAllocationRange(selection.tier, selection.level, 'weapon');
|
const core = coreAllocationRange(selection.tier, selection.level, 'weapon');
|
||||||
for (const key of coreKeys) selection[key] = core.min;
|
for (const key of coreKeys) selection[key] = core.min;
|
||||||
selection.talent1Id = item?.talent1Ids[0] ?? '';
|
selection.talent1Id = item?.talent1Ids[0] ?? '';
|
||||||
|
selection.talent1Tier = supportedTalentTier(selection.talent1Id, selection.tier >= 2 ? 2 : 1);
|
||||||
selection.talent2Id = item?.talent2Ids[0] ?? '';
|
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()];
|
selection.rolls = [blankRoll(), blankRoll()];
|
||||||
};
|
};
|
||||||
const configureChip = (index: number, id: string) => {
|
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.setId = id;
|
||||||
chip.primary = { statId: item?.primaryStatIds[0] ?? '', value: 0 };
|
chip.primary = blankRoll();
|
||||||
chip.secondary = [
|
chip.secondary = [];
|
||||||
{ statId: item?.secondaryStatIds[0] ?? '', value: 0 },
|
changeChipQuality(index, chip.quality);
|
||||||
{ statId: item?.secondaryStatIds[1] ?? '', value: 0 }
|
};
|
||||||
];
|
const changeChipQuality = (index: number, quality: SkillChipQuality) => {
|
||||||
if (chip.primary.statId) setRollStat(chip.primary, chip.primary.statId, 'chip');
|
if (!skillChipQualities.includes(quality)) return;
|
||||||
for (const roll of chip.secondary) if (roll.statId) setRollStat(roll, roll.statId, 'chip');
|
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 changeWeaponTier = (index: number, tier: number) => {
|
||||||
const selection = draft.weapons[index], item = weaponItem(index);
|
const selection = draft.weapons[index], item = weaponItem(index);
|
||||||
@@ -266,6 +342,27 @@
|
|||||||
};
|
};
|
||||||
const activeParts = $derived(active.split(':'));
|
const activeParts = $derived(active.split(':'));
|
||||||
const summary = $derived(summarizeLoadout(draft, data));
|
const summary = $derived(summarizeLoadout(draft, data));
|
||||||
|
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 rollSummary = (rolls: Roll[]) => rolls
|
const rollSummary = (rolls: Roll[]) => rolls
|
||||||
.filter((roll) => roll.statId)
|
.filter((roll) => roll.statId)
|
||||||
.map((roll) => `${attribute(roll.statId)?.name ?? 'Attribute'} ${unitValue(roll.value, roll.statId)}`)
|
.map((roll) => `${attribute(roll.statId)?.name ?? 'Attribute'} ${unitValue(roll.value, roll.statId)}`)
|
||||||
@@ -282,45 +379,49 @@
|
|||||||
const value = String(category ?? '').toLowerCase();
|
const value = String(category ?? '').toLowerCase();
|
||||||
if (value.includes('shotgun')) return '/shotgun.png';
|
if (value.includes('shotgun')) return '/shotgun.png';
|
||||||
if (value.includes('smg') || value.includes('submachine')) return '/smg.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';
|
return '/rifle.png';
|
||||||
};
|
};
|
||||||
const doctrineCell = $derived<MatrixCell>({
|
const doctrineCell = $derived<MatrixCell>({
|
||||||
id: 'specialization:focus',
|
id: 'specialization:focus',
|
||||||
label: 'SPECIALIZATION + FOCUS',
|
label: 'FOCUS',
|
||||||
name: selectedFocus ? `${selectedFocus.specialization.name} · ${selectedFocus.name}` : 'SELECT DOCTRINE',
|
name: selectedFocus ? `${selectedFocus.specialization.name} · ${selectedFocus.name}` : 'SELECT FOCUS',
|
||||||
meta: selectedFocus?.primary_attribute ?? '',
|
meta: selectedFocus?.primary_attribute ?? '',
|
||||||
summary: selectedFocus
|
summary: selectedFocus
|
||||||
? `${selectedFocus.primary_attribute} · ${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS`
|
? `${selectedFocus.primary_attribute} · ${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS`
|
||||||
: 'ROOT CHOICE · DEFINES ALL THREE ABILITIES',
|
: 'SETS 3 ABILITIES',
|
||||||
iconUrl: selectedFocus?.specialization.iconUrl,
|
iconUrl: selectedFocus?.specialization.iconUrl,
|
||||||
issue: !selectedFocus ? 'SELECT TO COMPLETE LOADOUT' : 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 abilityCells = $derived.by((): MatrixCell[] => [0, 1, 2].map((index) => {
|
||||||
const ability = selectedFocus?.abilities[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);
|
const chipNames = configuredChips.map((chip) => data.chipSets.find((item) => item.id === chip.setId)?.name).filter(Boolean);
|
||||||
return {
|
return {
|
||||||
id: `ability:${index}`,
|
id: `ability:${index}`,
|
||||||
label: `ABILITY 0${index + 1}`,
|
label: `ABILITY ${index + 1}`,
|
||||||
name: ability?.name ?? 'NOT ASSIGNED',
|
name: ability?.name ?? 'UNASSIGNED',
|
||||||
meta: ability?.attack?.damage_type ?? '',
|
meta: ability?.attack?.damage_type ?? '',
|
||||||
summary: chipNames.length ? chipNames.join(' · ') : `${configuredChips.length} / 3 SKILL CHIPS`,
|
summary: chipNames.length ? chipNames.join(' · ') : `${configuredChips.length} / 3 SKILL CHIPS`,
|
||||||
iconUrl: ability?.iconUrl,
|
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 weaponCells = $derived.by((): MatrixCell[] => draft.weapons.map((selection, index) => {
|
||||||
const item = weaponItem(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(' · ');
|
const details = [...talents, rollSummary(selection.rolls)].filter(Boolean).join(' · ');
|
||||||
return {
|
return {
|
||||||
id: `weapon:${index}`,
|
id: `weapon:${index}`,
|
||||||
label: `${index ? 'SECONDARY' : 'PRIMARY'} WEAPON`,
|
label: index ? 'SECONDARY' : 'PRIMARY',
|
||||||
name: item?.name ?? 'EMPTY SLOT',
|
name: item?.name ?? 'EMPTY',
|
||||||
meta: item?.category ?? '',
|
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),
|
iconUrl: weaponIcon(item?.category),
|
||||||
issue: item ? undefined : 'SELECT TO COMPLETE LOADOUT'
|
issue: item ? undefined : 'Choose a weapon.'
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
const gearCells = $derived.by((): MatrixCell[] => gearSlots.map((slot) => {
|
const gearCells = $derived.by((): MatrixCell[] => gearSlots.map((slot) => {
|
||||||
@@ -328,53 +429,64 @@
|
|||||||
const item = gearItem(slot);
|
const item = gearItem(slot);
|
||||||
const details = [
|
const details = [
|
||||||
brand(selection.brandId)?.name,
|
brand(selection.brandId)?.name,
|
||||||
talent(selection.talentId)?.name,
|
selection.talentId ? `${talent(selection.talentId)?.name ?? 'Talent'} T${selection.talentTier}` : '',
|
||||||
rollSummary(selection.rolls)
|
rollSummary(selection.rolls)
|
||||||
].filter(Boolean).join(' · ');
|
].filter(Boolean).join(' · ');
|
||||||
return {
|
return {
|
||||||
id: `gear:${slot}`,
|
id: `gear:${slot}`,
|
||||||
label: slot.toUpperCase(),
|
label: slot.toUpperCase(),
|
||||||
name: item?.name ?? 'EMPTY SLOT',
|
name: item?.name ?? 'EMPTY',
|
||||||
meta: item ? `T${item.tier}` : '',
|
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),
|
iconUrl: gearIcon(slot),
|
||||||
issue: item ? undefined : 'SELECT TO COMPLETE LOADOUT'
|
issue: item ? undefined : 'Choose gear.'
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
const protocolCell = $derived<MatrixCell>({
|
const protocolCell = $derived<MatrixCell>({
|
||||||
id: 'protocol:0',
|
id: 'protocol:0',
|
||||||
label: 'OS PROTOCOL',
|
label: 'PROTOCOL',
|
||||||
name: protocolItem()?.name ?? 'EMPTY SLOT',
|
name: protocolItem()?.name ?? 'EMPTY',
|
||||||
meta: protocolItem()?.quality ?? '',
|
meta: protocolItem()?.quality ?? '',
|
||||||
summary: protocolItem()
|
summary: protocolItem()
|
||||||
? `LEVEL ${draft.protocol.level} · ${draft.protocol.talentUpgrades} / 5 TALENT UPGRADES`
|
? `LEVEL ${draft.protocol.level} · ${draft.protocol.talentUpgrades} / 5 TALENT UPGRADES`
|
||||||
: 'SELECT ONE PROTOCOL',
|
: 'CHOOSE PROTOCOL',
|
||||||
iconUrl: protocolItem()?.iconUrl,
|
iconUrl: protocolItem()?.iconUrl,
|
||||||
issue: protocolItem() ? undefined : 'SELECT TO COMPLETE LOADOUT'
|
issue: protocolItem() ? undefined : 'Choose a protocol.'
|
||||||
});
|
});
|
||||||
const loadoutConfigured = $derived(Boolean(
|
const loadoutConfigured = $derived(Boolean(
|
||||||
selectedFocus
|
selectedFocus
|
||||||
&& selectedFocus.abilities.length === 3
|
&& selectedFocus.abilities.length === 3
|
||||||
&& draft.weapons.every((item) => item.itemId)
|
&& draft.weapons.every((item) => item.itemId)
|
||||||
&& gearSlots.every((slot) => draft.gear[slot].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
|
&& draft.protocol.itemId
|
||||||
));
|
));
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (data.initialBuild) return;
|
|
||||||
localStorage.removeItem('shd-build-loadout-draft-v3');
|
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;
|
if (!stored) return;
|
||||||
try {
|
try {
|
||||||
const parsed = parseLoadoutDraft(JSON.parse(stored));
|
const raw = JSON.parse(stored);
|
||||||
|
const parsed = current ? parseLoadoutDraft(raw) : migrateLoadoutDraftV5(raw, data);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
draft = 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 = '';
|
if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = '';
|
||||||
saved = true;
|
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 {
|
} catch {
|
||||||
localStorage.removeItem('shd-build-loadout-draft-v4');
|
localStorage.removeItem(current ? 'shd-build-loadout-draft-v6' : 'shd-build-loadout-draft-v5');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -392,7 +504,7 @@
|
|||||||
: 'Build published to the SHD archive.';
|
: 'Build published to the SHD archive.';
|
||||||
});
|
});
|
||||||
function saveDraft() {
|
function saveDraft() {
|
||||||
localStorage.setItem('shd-build-loadout-draft-v4', JSON.stringify(draft));
|
localStorage.setItem('shd-build-loadout-draft-v6', JSON.stringify(draft));
|
||||||
saved = true;
|
saved = true;
|
||||||
toast.success('Draft saved locally', 'This loadout is available on this device.');
|
toast.success('Draft saved locally', 'This loadout is available on this device.');
|
||||||
}
|
}
|
||||||
@@ -402,10 +514,10 @@
|
|||||||
<main class="app-page builder-page">
|
<main class="app-page builder-page">
|
||||||
<div class="builder-command">
|
<div class="builder-command">
|
||||||
<div class="page-title">
|
<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">
|
<div class="actions">
|
||||||
<span class:visible={saved}>SAVED LOCALLY</span>
|
<span class:visible={saved}>LOCAL SAVED</span>
|
||||||
<button class="secondary" onclick={saveDraft}>SAVE LOCALLY</button>
|
<button class="secondary" onclick={saveDraft}>SAVE</button>
|
||||||
{#if data.viewer}
|
{#if data.viewer}
|
||||||
<PendingForm action="?/save" class="builder-save-form">
|
<PendingForm action="?/save" class="builder-save-form">
|
||||||
{#snippet children(pending)}
|
{#snippet children(pending)}
|
||||||
@@ -421,21 +533,21 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if data.viewer}
|
{#if data.viewer}
|
||||||
<div class="builder-persistence">
|
<div class="builder-persistence">
|
||||||
<SegmentedChoice legend="ACCOUNT VISIBILITY" name="build-visibility" options={[
|
<SegmentedChoice legend="VISIBILITY" name="build-visibility" options={[
|
||||||
{ value:'private', label:'PRIVATE' },
|
{ value:'private', label:'PRIVATE' },
|
||||||
{ value:'unlisted', label:'UNLISTED' },
|
{ value:'unlisted', label:'UNLISTED' },
|
||||||
{ value:'public', label:'PUBLIC' }
|
{ value:'public', label:'PUBLIC' }
|
||||||
]} value={persistedVisibility} onchange={(values) => persistedVisibility = values[0] as BuildVisibility} required/>
|
]} value={persistedVisibility} onchange={(values) => persistedVisibility = values[0] as BuildVisibility} 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>
|
<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}
|
{#if persistedSlug && savedBuildVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="builder-status"><span><i></i> KNOWN STATS ONLINE</span><span>ADVANCED MODEL · COMING SOON</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>
|
</div>
|
||||||
{#if message}<p class="success" role="status">{message}</p>{/if}
|
{#if message}<p class="success" role="status">{message}</p>{/if}
|
||||||
{#if form?.buildError}<p class="error" role="alert">{form.buildError}</p>{/if}
|
{#if form?.buildError}<p class="error" role="alert">{form.buildError}</p>{/if}
|
||||||
{#if data.viewer && persistedSlug}<BuildShareControl title={savedBuildTitle} slugOrUrl={new URL(`/builds/${encodeURIComponent(persistedSlug)}`, data.publicOrigin).toString()} visibility={savedBuildVisibility}/>{/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>ISAC DATA LINK EMPTY</strong><span>Add active equipment definitions in ISAC to populate this workbench.</span></div>{/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">
|
<div class="loadout-workspace">
|
||||||
<LoadoutMatrix
|
<LoadoutMatrix
|
||||||
{active}
|
{active}
|
||||||
@@ -450,115 +562,142 @@
|
|||||||
|
|
||||||
<section class="loadout-editor">
|
<section class="loadout-editor">
|
||||||
{#if activeParts[0] === 'specialization'}
|
{#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>
|
<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="SPECIALIZATION + FOCUS"/>
|
<BuilderPicker items={specializationItems} value={draft.specialization.focusId} onchange={selectFocus} label="FOCUS"/>
|
||||||
{#if selectedFocus}
|
{#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-specialization-layout">
|
||||||
<div class="builder-ability-chip-panels">
|
<div class="builder-editor-lane">
|
||||||
{#each [0,1,2] as abilityIndex}
|
<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>
|
||||||
{@const ability = selectedFocus.abilities[abilityIndex]}
|
<div class="builder-focus-abilities" aria-label="Assigned abilities">
|
||||||
<section>
|
{#each [0,1,2] as abilityIndex}
|
||||||
<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>
|
{@const ability = selectedFocus.abilities[abilityIndex]}
|
||||||
<p>{ability?.description ?? 'ISAC requires this class ability before compatible Skill Chips can be equipped.'}</p>
|
<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>
|
||||||
<div>{#each [0,1,2] as chipSlot}{@const chipIndex = abilityIndex * 3 + chipSlot}{@const item = chipItem(chipIndex)}<button type="button" onclick={() => active = `chip:${chipIndex}`}><small>CHIP {chipSlot + 1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? draft.chips[chipIndex].quality : 'SELECT CHIP'}</span></button>{/each}</div>
|
|
||||||
</section>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="builder-specialization-telemetry"><span>POINTS <b>{selectedSpecializationPoints} / {data.specializationProgression.availablePoints}</b></span><span>CREDITS <b>{selectedSpecializationCosts.credits.toLocaleString()}</b></span>{#each selectedSpecializationCosts.resources as resource}<span>{String(resource[0]).replace('resource-electronics-','').replaceAll('-',' ').toUpperCase()} <b>× {resource[1]}</b></span>{/each}</div>
|
|
||||||
<div class="builder-focus-tree">
|
|
||||||
<div class="builder-section-title"><div><span>FOCUS UPGRADE TREE</span><small>PREVIOUS ROWS REQUIRE EVERY AVAILABLE RANK 1 + 2 BEFORE ADVANCING</small></div><b>{selectedFocus.nodes.length} NODES</b></div>
|
|
||||||
{#each [...new Set(selectedFocus.nodes.map((node: any) => Number(node.row_number)))] as row}
|
|
||||||
<section><header><span>ROW {String(row).padStart(2, '0')}</span><b>{row === 1 ? 'ENTRY ROW' : 'LOCKED BY PREVIOUS ROWS'}</b></header><div>
|
|
||||||
{#each selectedFocus.nodes.filter((node: any) => Number(node.row_number) === row) as node}
|
|
||||||
<article><ItemIcon src={node.iconUrl} alt={node.name}/><div><strong>{node.name}</strong><small>{node.description_template}</small></div><SegmentedChoice legend={`${node.name} rank`} name={`specialization-node-${node.id}`} options={[{value:'0',label:'OFF'},...Array.from({length:Number(node.max_rank)},(_,index)=>({value:String(index+1),label:`R${index+1}`,className:canSelectNodeRank(node,index+1)?'':'unavailable',disabled:!canSelectNodeRank(node,index+1)}))]} value={String(draft.specialization.nodeRanks[node.id] ?? 0)} onchange={(values) => setNodeRank(node,Number(values[0]))}/></article>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div></section>
|
</div>
|
||||||
{/each}
|
<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>
|
</div>
|
||||||
<PlannedCombatModel headingId="builder-combat-model-heading" />
|
|
||||||
{/if}
|
{/if}
|
||||||
{:else if activeParts[0] === 'ability'}
|
{:else if activeParts[0] === 'ability'}
|
||||||
{@const abilityIndex = Number(activeParts[1])}
|
{@const abilityIndex = Number(activeParts[1])}
|
||||||
{@const ability = selectedFocus?.abilities[abilityIndex]}
|
{@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>
|
<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'}>← CHANGE SPECIALIZATION + FOCUS</button>
|
<button class="builder-return-link" type="button" onclick={() => active = 'specialization:focus'}>← BACK TO FOCUS</button>
|
||||||
{#if ability}
|
{#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-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>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-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">
|
<div class="builder-ability-chip-panels single">
|
||||||
<section>
|
<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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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}
|
{/if}
|
||||||
{:else if activeParts[0] === 'gear'}
|
{:else if activeParts[0] === 'gear'}
|
||||||
{@const slot = activeParts[1]}{@const selection = draft.gear[slot]}{@const item = gearItem(slot)}
|
{@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>
|
<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="ITEM DEFINITION"/>
|
<BuilderPicker items={data.gear.filter((entry) => entry.slot === slot)} bind:value={selection.itemId} onchange={(id) => configureGear(slot,id)} label="GEAR ITEM"/>
|
||||||
{#if item}
|
{#if item}
|
||||||
{@const healthRange = gearBaseRange(item, 'health', selection.level)}
|
{@const healthRange = gearBaseRange(item, 'health', selection.level)}
|
||||||
{@const armorRange = item.base_armor_min !== null ? gearBaseRange(item, 'armor', selection.level) : null}
|
{@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')}
|
{@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)}
|
{@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}
|
{/if}
|
||||||
{:else if activeParts[0] === 'weapon'}
|
{: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)}
|
{@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>
|
<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 DEFINITION"/>
|
<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 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}
|
{#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)}
|
{@const range = item.ranges.find((entry: any) => Number(entry.tier) === selection.tier)}
|
||||||
{#if range}
|
{#if range}{@const damageRange = weaponStatRange(range,'damage',selection.level)}{@const weaponCore = coreAllocationRange(selection.tier, selection.level, 'weapon')}
|
||||||
<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>
|
<div class="builder-editor-columns">
|
||||||
{@const damageRange = weaponStatRange(range,'damage',selection.level)}
|
<section class="builder-editor-lane" aria-label="Weapon identity and base values">
|
||||||
<div class="builder-range-grid weapon-damage"><RangeValueControl label="DAMAGE" min={damageRange.min} max={damageRange.max} provisional={damageRange.provisional} bind:value={selection.damage}/></div>
|
<div class="builder-weapon-controls">
|
||||||
{@const weaponCore = coreAllocationRange(selection.tier, selection.level, 'weapon')}
|
<RangeValueControl label="WEAPON LEVEL" min={selection.tier === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeWeaponLevel(index,value)}/>
|
||||||
<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>
|
<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-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={weaponCore.poolMax} bind:value={selection[key]}/>{/each}</div>
|
<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}
|
{/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}
|
{/if}
|
||||||
{:else if activeParts[0] === 'chip'}
|
{: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]}
|
{@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>
|
<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}`}>← RETURN TO {ability?.name?.toUpperCase() ?? 'ABILITY'}</button>
|
<button class="builder-return-link" type="button" onclick={() => active = `ability:${abilityIndex}`}>← BACK TO 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 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 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}
|
{#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}
|
{:else}
|
||||||
{@const protocol = protocolItem()}
|
{@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>
|
<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="OS PROTOCOL"/>
|
<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 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}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="loadout-summary">
|
<aside class="loadout-summary">
|
||||||
<LoadoutStatsSummary {summary} attributes={data.attributes} talents={data.talents} headingId="builder-known-stats-heading" />
|
<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>
|
<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>
|
<p class="notice">Anonymous drafts remain on this device. Account saves preserve the equipped-item configuration and current visibility.</p>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
const chip = (id: string) => data.chips.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 protocol = (id: string) => data.protocols.find((item) => item.id === id);
|
||||||
const brand = (id: string) => data.brands.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 attribute = (id: string) => data.attributes.find((item) => item.id === id);
|
||||||
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
|
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
|
||||||
const rollLine = (rolls: Array<{ statId: string; value: number }>) => rolls
|
const rollLine = (rolls: Array<{ statId: string; value: number }>) => rolls
|
||||||
@@ -56,15 +57,15 @@
|
|||||||
<div class="shared-build-grid">
|
<div class="shared-build-grid">
|
||||||
<section>
|
<section>
|
||||||
<header><span>WEAPONS</span><b>02 SLOTS</b></header>
|
<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} · ${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></div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</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>
|
||||||
<section>
|
<section>
|
||||||
<header><span>GEAR</span><b>06 SLOTS</b></header>
|
<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>{#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}</div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</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>
|
||||||
<section>
|
<section>
|
||||||
<header><span>AGENT SYSTEMS</span><b>10 SLOTS</b></header>
|
<header><span>AGENT SYSTEMS</span><b>10 SLOTS</b></header>
|
||||||
{#each data.loadout.chips as selection, index}{@const item=chip(selection.setId)}<article><small>SKILL CHIP 0{index+1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? selection.quality : 'NOT CONFIGURED'}</span>{#if item}<em>{rollLine([selection.primary, ...selection.secondary]) || 'ATTRIBUTES NOT CONFIGURED'}</em>{/if}</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}
|
{#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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ import { recordIsacAudit } from '$lib/server/audit';
|
|||||||
import { removePublicComment } from '$lib/server/comment-removal';
|
import { removePublicComment } from '$lib/server/comment-removal';
|
||||||
import { createNotification } from '$lib/server/notifications';
|
import { createNotification } from '$lib/server/notifications';
|
||||||
import type { Actions } from './$types';
|
import type { Actions } from './$types';
|
||||||
|
import {
|
||||||
|
publicWeaponModSlots,
|
||||||
|
weaponModSlotDefinitionByName,
|
||||||
|
} from '$lib/weapons/mod-slots';
|
||||||
|
|
||||||
const number = (value: unknown) => {
|
const number = (value: unknown) => {
|
||||||
const numeric = Number(value);
|
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,
|
rowsSql = `SELECT item.*,render.id AS render_id,render.updated_at AS render_updated_at,
|
||||||
native.damage_min,native.damage_max,native.reference_level,
|
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=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_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
|
|
||||||
FROM weapons item
|
FROM weapons item
|
||||||
LEFT JOIN weapon_tier_ranges native ON native.weapon_id=item.id AND native.tier=item.native_tier
|
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
|
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,
|
rowsSql = `SELECT item.*,weapons.name AS exotic_weapon,
|
||||||
render.id AS render_id,render.updated_at AS render_updated_at,
|
render.id AS render_id,render.updated_at AS render_updated_at,
|
||||||
GROUP_CONCAT(DISTINCT categories.weapon_category) AS categories,
|
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
|
(SELECT COUNT(*) FROM weapon_mod_effects effects WHERE effects.weapon_mod_id=item.id) AS effect_count
|
||||||
FROM weapon_mods item
|
FROM weapon_mods item
|
||||||
LEFT JOIN weapons ON weapons.id=item.exotic_weapon_id
|
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':
|
case 'gear-mods':
|
||||||
countSql = `SELECT COUNT(*) AS total FROM gear_mods item WHERE item.active=1${searchClause}`;
|
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,
|
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 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
|
(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
|
FROM gear_mods item
|
||||||
@@ -409,7 +412,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
|||||||
case 'os-protocols':
|
case 'os-protocols':
|
||||||
countSql = `SELECT COUNT(*) AS total FROM os_protocols item WHERE item.active=1${searchClause}`;
|
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,
|
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
|
render.id AS render_id,render.updated_at AS render_updated_at
|
||||||
FROM os_protocols item
|
FROM os_protocols item
|
||||||
LEFT JOIN os_protocol_primary_effects primary_effect ON primary_effect.protocol_id=item.id
|
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}`;
|
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,
|
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,
|
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_primary_options) 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_secondary_options) AS secondary_count,
|
||||||
icons.updated_at AS icon_updated_at,render.id AS render_id,render.updated_at AS render_updated_at
|
icons.updated_at AS icon_updated_at,render.id AS render_id,render.updated_at AS render_updated_at
|
||||||
FROM skill_chip_sets item
|
FROM skill_chip_sets item
|
||||||
LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id
|
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 (
|
const rows = await (
|
||||||
await database.prepare(rowsSql)
|
await database.prepare(rowsSql)
|
||||||
).all(...queryArgs, pagination.perPage, pagination.offset);
|
).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 =
|
const weaponRelations =
|
||||||
category.slug === 'weapons' && rows.length
|
category.slug === 'weapons' && rows.length
|
||||||
? await (
|
? await (
|
||||||
@@ -563,6 +577,22 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
|||||||
ORDER BY options.talent_slot,talents.name COLLATE NOCASE`)
|
ORDER BY options.talent_slot,talents.name COLLATE NOCASE`)
|
||||||
).all(...rows.map((row) => row.id))
|
).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 =
|
const gearRelations =
|
||||||
category.slug === 'gear' && rows.length
|
category.slug === 'gear' && rows.length
|
||||||
? await (
|
? await (
|
||||||
@@ -769,6 +799,36 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
|||||||
const relatedTalents = weaponRelations.filter(
|
const relatedTalents = weaponRelations.filter(
|
||||||
(relation) => relation.weapon_id === row.id,
|
(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) =>
|
const talentItems = (slot: number) =>
|
||||||
relatedTalents
|
relatedTalents
|
||||||
.filter((relation) => Number(relation.talent_slot) === slot)
|
.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)
|
? managedImageUrl(String(row.render_id), row.render_updated_at)
|
||||||
: '',
|
: '',
|
||||||
gameIcon: weaponIcon(row.category),
|
gameIcon: weaponIcon(row.category),
|
||||||
headerBadge: `T${row.native_tier}`,
|
headerBadges: [
|
||||||
|
`T${row.native_tier}`,
|
||||||
|
...(Number(row.is_exotic) ? ['EXOTIC'] : []),
|
||||||
|
],
|
||||||
headerMetaIcon: weaponIcon(row.damage_type),
|
headerMetaIcon: weaponIcon(row.damage_type),
|
||||||
badges: Number(row.is_exotic)
|
|
||||||
? [{ label: 'EXOTIC', tone: 'exotic' }]
|
|
||||||
: [],
|
|
||||||
factLayout: 'stack' as const,
|
factLayout: 'stack' as const,
|
||||||
facts: [
|
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: [
|
iconGroups: [
|
||||||
{
|
{
|
||||||
label: 'TALENT 1 POOL',
|
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)
|
? managedImageUrl(String(row.render_id), row.render_updated_at)
|
||||||
: '',
|
: '',
|
||||||
iconUrl: iconUrl('os_protocol', row.id, row.icon_updated_at),
|
iconUrl: iconUrl('os_protocol', row.id, row.icon_updated_at),
|
||||||
badges: [
|
topBorderTone: String(row.quality).toLowerCase().replace('-', ''),
|
||||||
{
|
headerBadge: String(row.quality).toUpperCase(),
|
||||||
label: String(row.quality),
|
headerBadgeTone: String(row.quality).toLowerCase().replace('-', ''),
|
||||||
tone: String(row.quality).toLowerCase().replace('-', ''),
|
factLayout: 'stack' as const,
|
||||||
},
|
facts: [],
|
||||||
],
|
protocolPreview: {
|
||||||
facts: [
|
primaryStat: String(row.primary_stat ?? 'NOT CONFIGURED'),
|
||||||
{ label: 'LEVELS', value: '01–50' },
|
primaryUnit: String(row.primary_unit ?? ''),
|
||||||
{
|
primaryBaseValue: Number(row.base_value),
|
||||||
label: 'PRIMARY EFFECT',
|
talentDescription: row.talent_description
|
||||||
value: row.primary_stat
|
? String(row.talent_description)
|
||||||
? `${row.primary_stat} · ${valueWithUnit(row.base_value, row.primary_unit)}`
|
: undefined,
|
||||||
: 'NOT CONFIGURED',
|
talentEffects: protocolTalentEffects
|
||||||
},
|
.filter((effect) => effect.protocol_id === row.id)
|
||||||
{ label: 'TALENT UPGRADES', value: '0–5' },
|
.map((effect) => ({
|
||||||
],
|
baseValue: Number(effect.base_value),
|
||||||
lines: [
|
unit: String(effect.unit ?? ''),
|
||||||
{
|
})),
|
||||||
label: 'UNIQUE TALENT',
|
},
|
||||||
value: list(row.talent_name, 'NOT CONFIGURED'),
|
description: row.talent_description
|
||||||
},
|
? String(row.talent_description)
|
||||||
],
|
: undefined,
|
||||||
};
|
};
|
||||||
case 'talents':
|
case 'talents':
|
||||||
return talentCardItem(row, mechanicsFor(row.id));
|
return talentCardItem(row, mechanicsFor(row.id));
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
'gearMax',
|
'gearMax',
|
||||||
'weaponMin',
|
'weaponMin',
|
||||||
'weaponMax',
|
'weaponMax',
|
||||||
'chipMin',
|
|
||||||
'chipMax',
|
|
||||||
'capValue',
|
'capValue',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -47,8 +45,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
gear_max: stat.gearMax || null,
|
gear_max: stat.gearMax || null,
|
||||||
weapon_min: stat.weaponMin || null,
|
weapon_min: stat.weaponMin || null,
|
||||||
weapon_max: stat.weaponMax || null,
|
weapon_max: stat.weaponMax || null,
|
||||||
skill_chip_min: stat.chipMin || null,
|
|
||||||
skill_chip_max: stat.chipMax || null,
|
|
||||||
cap_value: stat.capValue || null,
|
cap_value: stat.capValue || null,
|
||||||
}));
|
}));
|
||||||
const summary = await (
|
const summary = await (
|
||||||
@@ -211,14 +207,9 @@ export const actions: Actions = {
|
|||||||
let values: Array<number | null>;
|
let values: Array<number | null>;
|
||||||
try {
|
try {
|
||||||
values = [
|
values = [
|
||||||
...[
|
...['gearMin', 'gearMax', 'weaponMin', 'weaponMax'].map((key) =>
|
||||||
'gearMin',
|
number(key),
|
||||||
'gearMax',
|
),
|
||||||
'weaponMin',
|
|
||||||
'weaponMax',
|
|
||||||
'chipMin',
|
|
||||||
'chipMax',
|
|
||||||
].map((key) => number(key)),
|
|
||||||
number('capValue', false),
|
number('capValue', false),
|
||||||
];
|
];
|
||||||
} catch {
|
} catch {
|
||||||
@@ -226,7 +217,7 @@ export const actions: Actions = {
|
|||||||
error: 'Ranges must be numbers. Optional caps cannot be negative.',
|
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 (
|
if (
|
||||||
(values[index] === null) !== (values[index + 1] === null) ||
|
(values[index] === null) !== (values[index + 1] === null) ||
|
||||||
(values[index] !== null &&
|
(values[index] !== null &&
|
||||||
@@ -253,7 +244,7 @@ export const actions: Actions = {
|
|||||||
if (id)
|
if (id)
|
||||||
await (
|
await (
|
||||||
await database.prepare(
|
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(
|
).run(
|
||||||
name,
|
name,
|
||||||
@@ -269,7 +260,7 @@ export const actions: Actions = {
|
|||||||
else
|
else
|
||||||
await (
|
await (
|
||||||
await database.prepare(
|
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(
|
).run(
|
||||||
statId,
|
statId,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
const categoryOptions = ['Weapon', 'Skill', 'Character'].map((value) => ({ value, label: value }));
|
const categoryOptions = ['Weapon', 'Skill', 'Character'].map((value) => ({ value, label: value }));
|
||||||
const calculationOptions = ['Additive', 'Multiplicative'].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 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 value = (input: unknown) => input ?? '';
|
||||||
const attributeMeta = (stat: any) => stat.pending
|
const attributeMeta = (stat: any) => stat.pending
|
||||||
? `PROPOSED BY ${stat.proposerName}`
|
? `PROPOSED BY ${stat.proposerName}`
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
</script>
|
</script>
|
||||||
{#snippet attributeEditor()}
|
{#snippet attributeEditor()}
|
||||||
{#if editing}
|
{#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}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
<svelte:head><title>Attributes — SHD.build admin</title></svelte:head>
|
<svelte:head><title>Attributes — SHD.build admin</title></svelte:head>
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
await (
|
await (
|
||||||
await database.prepare(`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,
|
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=?,active=?,updated_at=? WHERE id=?`)
|
weapon_min=?,weapon_max=?,cap_value=?,active=?,updated_at=? WHERE id=?`)
|
||||||
).run(
|
).run(
|
||||||
before.name,
|
before.name,
|
||||||
before.description ?? null,
|
before.description ?? null,
|
||||||
@@ -253,8 +253,6 @@ export const actions: Actions = {
|
|||||||
before.gear_max,
|
before.gear_max,
|
||||||
before.weapon_min,
|
before.weapon_min,
|
||||||
before.weapon_max,
|
before.weapon_max,
|
||||||
before.skill_chip_min,
|
|
||||||
before.skill_chip_max,
|
|
||||||
before.cap_value,
|
before.cap_value,
|
||||||
before.active,
|
before.active,
|
||||||
new Date().toISOString(),
|
new Date().toISOString(),
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ import { recordIsacAudit } from '$lib/server/audit';
|
|||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { createNotification } from '$lib/server/notifications';
|
import { createNotification } from '$lib/server/notifications';
|
||||||
import { conditionalRuleSnapshot } from '$lib/server/conditional-effects';
|
import { conditionalRuleSnapshot } from '$lib/server/conditional-effects';
|
||||||
|
import {
|
||||||
|
getSkillChipPrimaryProfiles,
|
||||||
|
getSkillChipSecondaryPool,
|
||||||
|
} from '$lib/server/skill-chip-profiles';
|
||||||
|
import { gameDataIconMetadata } from '$lib/server/game-data-icons';
|
||||||
|
import { weaponModSlotDefinitionById } from '$lib/weapons/mod-slots';
|
||||||
|
|
||||||
const statuses = ['pending', 'approved', 'rejected', 'failed'];
|
const statuses = ['pending', 'approved', 'rejected', 'failed'];
|
||||||
const modules = [
|
const modules = [
|
||||||
@@ -92,6 +98,14 @@ async function specializationProposalSnapshot(database: any, proposal: any) {
|
|||||||
'SELECT * FROM specialization_ability_effects WHERE ability_id=? ORDER BY effect_order',
|
'SELECT * FROM specialization_ability_effects WHERE ability_id=? ORDER BY effect_order',
|
||||||
)
|
)
|
||||||
).all(id),
|
).all(id),
|
||||||
|
skillChipLinks: await (
|
||||||
|
await database.prepare(
|
||||||
|
`SELECT ability_id,compatible_ability_id
|
||||||
|
FROM specialization_ability_skill_chip_links
|
||||||
|
WHERE ability_id=? OR compatible_ability_id=?
|
||||||
|
ORDER BY ability_id,compatible_ability_id`,
|
||||||
|
)
|
||||||
|
).all(id, id),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (proposal.action === 'saveCombatEffect') {
|
if (proposal.action === 'saveCombatEffect') {
|
||||||
@@ -174,16 +188,6 @@ async function skillChipProposalSnapshot(database: any, id: string) {
|
|||||||
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?',
|
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?',
|
||||||
)
|
)
|
||||||
).get(id)) ?? null,
|
).get(id)) ?? null,
|
||||||
primaryOptions: await (
|
|
||||||
await database.prepare(
|
|
||||||
'SELECT stat_attribute_id FROM skill_chip_primary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id',
|
|
||||||
)
|
|
||||||
).all(id),
|
|
||||||
secondaryOptions: await (
|
|
||||||
await database.prepare(
|
|
||||||
'SELECT stat_attribute_id FROM skill_chip_secondary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id',
|
|
||||||
)
|
|
||||||
).all(id),
|
|
||||||
talent: talent ?? null,
|
talent: talent ?? null,
|
||||||
talentEffects: talent
|
talentEffects: talent
|
||||||
? await (
|
? await (
|
||||||
@@ -317,16 +321,35 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
? ((await (
|
? ((await (
|
||||||
await database.prepare('SELECT * FROM stat_attributes WHERE id=?')
|
await database.prepare('SELECT * FROM stat_attributes WHERE id=?')
|
||||||
).get(String(proposal.entity_id))) ?? null)
|
).get(String(proposal.entity_id))) ?? null)
|
||||||
: proposal.module === 'skillchips' && proposal.entity_id
|
: proposal.module === 'skillchips' &&
|
||||||
? await skillChipProposalSnapshot(
|
proposal.action === 'savePrimaryProfile'
|
||||||
database,
|
? await getSkillChipPrimaryProfiles(database)
|
||||||
String(proposal.entity_id),
|
: proposal.module === 'skillchips' &&
|
||||||
)
|
proposal.action === 'saveSecondaryPool'
|
||||||
: proposal.module === 'calculations'
|
? await getSkillChipSecondaryPool(database)
|
||||||
? await calculationProposalSnapshot(database, proposal)
|
: proposal.module === 'weaponmods' &&
|
||||||
: proposal.module === 'specializations'
|
proposal.action === 'saveSlotIcon' &&
|
||||||
? await specializationProposalSnapshot(database, proposal)
|
proposal.entity_id
|
||||||
: null,
|
? {
|
||||||
|
slot: weaponModSlotDefinitionById(
|
||||||
|
String(proposal.entity_id),
|
||||||
|
),
|
||||||
|
icon: await gameDataIconMetadata(
|
||||||
|
database,
|
||||||
|
'weapon_mod_slot',
|
||||||
|
String(proposal.entity_id),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: proposal.module === 'skillchips' && proposal.entity_id
|
||||||
|
? await skillChipProposalSnapshot(
|
||||||
|
database,
|
||||||
|
String(proposal.entity_id),
|
||||||
|
)
|
||||||
|
: proposal.module === 'calculations'
|
||||||
|
? await calculationProposalSnapshot(database, proposal)
|
||||||
|
: proposal.module === 'specializations'
|
||||||
|
? await specializationProposalSnapshot(database, proposal)
|
||||||
|
: null,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
const summaryRows = await (
|
const summaryRows = await (
|
||||||
|
|||||||
@@ -21,8 +21,16 @@ import {
|
|||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import {
|
||||||
|
MOD_QUALITIES,
|
||||||
|
normalizeModSecondaryPool,
|
||||||
|
} from '$lib/mods/secondary-pool';
|
||||||
|
import {
|
||||||
|
getModSecondaryPool,
|
||||||
|
replaceModSecondaryPool,
|
||||||
|
} from '$lib/server/mod-secondary-pools';
|
||||||
|
|
||||||
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'] as const;
|
const qualities = MOD_QUALITIES;
|
||||||
type QualityValue = {
|
type QualityValue = {
|
||||||
quality: string;
|
quality: string;
|
||||||
primaryLevel1Min: number;
|
primaryLevel1Min: number;
|
||||||
@@ -30,11 +38,6 @@ type QualityValue = {
|
|||||||
primaryLevel20Min: number;
|
primaryLevel20Min: number;
|
||||||
primaryLevel20Max: number;
|
primaryLevel20Max: number;
|
||||||
};
|
};
|
||||||
type SecondaryRange = {
|
|
||||||
statId: string;
|
|
||||||
values: Record<string, { min: number; max: number }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function parseJson<T>(value: FormDataEntryValue | null, fallback: T): T {
|
function parseJson<T>(value: FormDataEntryValue | null, fallback: T): T {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(String(value ?? '')) as T;
|
return JSON.parse(String(value ?? '')) as T;
|
||||||
@@ -52,16 +55,6 @@ async function snapshot(id: string) {
|
|||||||
'SELECT * FROM gear_mod_quality_values WHERE gear_mod_id=? ORDER BY quality',
|
'SELECT * FROM gear_mod_quality_values WHERE gear_mod_id=? ORDER BY quality',
|
||||||
)
|
)
|
||||||
).all(id),
|
).all(id),
|
||||||
secondaryOptions: await (
|
|
||||||
await database.prepare(
|
|
||||||
'SELECT stat_attribute_id FROM gear_mod_secondary_options WHERE gear_mod_id=? ORDER BY stat_attribute_id',
|
|
||||||
)
|
|
||||||
).all(id),
|
|
||||||
secondaryQualityValues: await (
|
|
||||||
await database.prepare(
|
|
||||||
'SELECT * FROM gear_mod_secondary_quality_values WHERE gear_mod_id=? ORDER BY stat_attribute_id,quality',
|
|
||||||
)
|
|
||||||
).all(id),
|
|
||||||
render:
|
render:
|
||||||
(await (
|
(await (
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
@@ -87,10 +80,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
asc: 'name COLLATE NOCASE ASC,id ASC',
|
asc: 'name COLLATE NOCASE ASC,id ASC',
|
||||||
desc: 'name COLLATE NOCASE DESC,id DESC',
|
desc: 'name COLLATE NOCASE DESC,id DESC',
|
||||||
},
|
},
|
||||||
secondaries: {
|
|
||||||
asc: '(SELECT COUNT(*) FROM gear_mod_secondary_options option WHERE option.gear_mod_id=gear_mods.id) ASC,name COLLATE NOCASE ASC',
|
|
||||||
desc: '(SELECT COUNT(*) FROM gear_mod_secondary_options option WHERE option.gear_mod_id=gear_mods.id) DESC,name COLLATE NOCASE ASC',
|
|
||||||
},
|
|
||||||
state: {
|
state: {
|
||||||
asc: 'active ASC,name COLLATE NOCASE ASC',
|
asc: 'active ASC,name COLLATE NOCASE ASC',
|
||||||
desc: 'active DESC,name COLLATE NOCASE ASC',
|
desc: 'active DESC,name COLLATE NOCASE ASC',
|
||||||
@@ -140,7 +129,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
module: 'gearmods',
|
module: 'gearmods',
|
||||||
action: 'save',
|
action: 'save',
|
||||||
kind: 'gearmod',
|
kind: 'gearmod',
|
||||||
fields: ['qualityValues', 'secondaryRanges'],
|
fields: ['qualityValues'],
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
@@ -161,38 +150,29 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
pagination.page === 1 ? pendingRows.slice(0, pagination.perPage) : [];
|
pagination.page === 1 ? pendingRows.slice(0, pagination.perPage) : [];
|
||||||
const databaseOffset = Math.max(0, pagination.offset - pendingRows.length);
|
const databaseOffset = Math.max(0, pagination.offset - pendingRows.length);
|
||||||
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
|
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
|
||||||
const [mods, qualityRows, secondaryOptions, secondaryValues, stats, renders] =
|
const [mods, qualityRows, stats, renders, secondaryPool] = await Promise.all([
|
||||||
await Promise.all([
|
databaseLimit
|
||||||
databaseLimit
|
? (
|
||||||
? (
|
await database.prepare(`SELECT * FROM gear_mods ${where}
|
||||||
await database.prepare(`SELECT * FROM gear_mods ${where}
|
|
||||||
ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)
|
ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)
|
||||||
).all(...args, databaseLimit, databaseOffset)
|
).all(...args, databaseLimit, databaseOffset)
|
||||||
: [],
|
: [],
|
||||||
(await database.prepare('SELECT * FROM gear_mod_quality_values')).all(),
|
(await database.prepare('SELECT * FROM gear_mod_quality_values')).all(),
|
||||||
(
|
(
|
||||||
await database.prepare('SELECT * FROM gear_mod_secondary_options')
|
await database.prepare(
|
||||||
).all(),
|
'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||||
(
|
)
|
||||||
await database.prepare(
|
).all(),
|
||||||
'SELECT * FROM gear_mod_secondary_quality_values',
|
(
|
||||||
)
|
await database.prepare(
|
||||||
).all(),
|
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='gear_mod_render'",
|
||||||
(
|
)
|
||||||
await database.prepare(
|
).all(),
|
||||||
'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
|
getModSecondaryPool(database, 'gear'),
|
||||||
)
|
]);
|
||||||
).all(),
|
|
||||||
(
|
|
||||||
await database.prepare(
|
|
||||||
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='gear_mod_render'",
|
|
||||||
)
|
|
||||||
).all(),
|
|
||||||
]);
|
|
||||||
const parsedPending = pendingOnPage.map((item) => ({
|
const parsedPending = pendingOnPage.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
qualityValues: parseJson(String(item.qualityValues), []),
|
qualityValues: parseJson(String(item.qualityValues), []),
|
||||||
secondaryRanges: parseJson(String(item.secondaryRanges), []),
|
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
mods: [
|
mods: [
|
||||||
@@ -208,23 +188,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
primaryLevel20Min: Number(row.primary_level_20_min),
|
primaryLevel20Min: Number(row.primary_level_20_min),
|
||||||
primaryLevel20Max: Number(row.primary_level_20_max),
|
primaryLevel20Max: Number(row.primary_level_20_max),
|
||||||
})),
|
})),
|
||||||
secondaryRanges: secondaryOptions
|
|
||||||
.filter((option) => option.gear_mod_id === mod.id)
|
|
||||||
.map((option) => ({
|
|
||||||
statId: String(option.stat_attribute_id),
|
|
||||||
values: Object.fromEntries(
|
|
||||||
secondaryValues
|
|
||||||
.filter(
|
|
||||||
(row) =>
|
|
||||||
row.gear_mod_id === mod.id &&
|
|
||||||
row.stat_attribute_id === option.stat_attribute_id,
|
|
||||||
)
|
|
||||||
.map((row) => [
|
|
||||||
String(row.quality),
|
|
||||||
{ min: Number(row.min_value), max: Number(row.max_value) },
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
renderUrl: (() => {
|
renderUrl: (() => {
|
||||||
const render = renders.find(
|
const render = renders.find(
|
||||||
(item) => item.source_entity_id === mod.id,
|
(item) => item.source_entity_id === mod.id,
|
||||||
@@ -235,6 +198,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
})(),
|
})(),
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
|
secondaryPool,
|
||||||
stats: [...pendingStats, ...stats],
|
stats: [...pendingStats, ...stats],
|
||||||
qualities,
|
qualities,
|
||||||
summary: {
|
summary: {
|
||||||
@@ -251,6 +215,60 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
|
saveSecondaryPool: async ({ request, cookies }) => {
|
||||||
|
const operator = await requirePermission(
|
||||||
|
await getSessionUser(cookies),
|
||||||
|
'data.manage',
|
||||||
|
);
|
||||||
|
const form = await request.formData();
|
||||||
|
if (String(form.get('id') ?? '') !== 'gear-mod-secondary-pool') {
|
||||||
|
return fail(400, {
|
||||||
|
gearModSecondaryPoolError:
|
||||||
|
'The shared Gear Mod pool target is invalid.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const normalized = normalizeModSecondaryPool(
|
||||||
|
parseJson<unknown>(form.get('secondaryRanges'), null),
|
||||||
|
);
|
||||||
|
if (normalized.error) {
|
||||||
|
return fail(400, { gearModSecondaryPoolError: normalized.error });
|
||||||
|
}
|
||||||
|
const database = await getDatabase();
|
||||||
|
const statIds = normalized.pool.map((range) => range.statId);
|
||||||
|
const validStats = await (
|
||||||
|
await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes
|
||||||
|
WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)
|
||||||
|
).get(...statIds);
|
||||||
|
if (Number(validStats?.count) !== statIds.length) {
|
||||||
|
return fail(400, {
|
||||||
|
gearModSecondaryPoolError:
|
||||||
|
'One or more selected Attributes are unavailable.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await database.exec('BEGIN IMMEDIATE');
|
||||||
|
try {
|
||||||
|
const before = await getModSecondaryPool(database, 'gear');
|
||||||
|
await replaceModSecondaryPool(database, 'gear', normalized.pool);
|
||||||
|
await recordIsacAudit({
|
||||||
|
actorId: operator.id,
|
||||||
|
category: 'game_data',
|
||||||
|
action: 'gear_mod_secondary_pool.updated',
|
||||||
|
entityId: 'gear-mod-secondary-pool',
|
||||||
|
before,
|
||||||
|
after: await getModSecondaryPool(database, 'gear'),
|
||||||
|
database,
|
||||||
|
});
|
||||||
|
await database.exec('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await database.exec('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
gearModSecondaryPoolSaved: true,
|
||||||
|
gearModSecondaryPoolMode: 'edit',
|
||||||
|
resultToken: randomUUID(),
|
||||||
|
};
|
||||||
|
},
|
||||||
save: async ({ request, cookies }) => {
|
save: async ({ request, cookies }) => {
|
||||||
const operator = await requirePermission(
|
const operator = await requirePermission(
|
||||||
await getSessionUser(cookies),
|
await getSessionUser(cookies),
|
||||||
@@ -275,14 +293,16 @@ export const actions: Actions = {
|
|||||||
const name = String(form.get('name') ?? '')
|
const name = String(form.get('name') ?? '')
|
||||||
.normalize('NFKC')
|
.normalize('NFKC')
|
||||||
.trim();
|
.trim();
|
||||||
|
if (form.has('secondaryRanges')) {
|
||||||
|
return fail(400, {
|
||||||
|
gearModError:
|
||||||
|
'Secondary Attributes must be submitted through the shared pool editor.',
|
||||||
|
});
|
||||||
|
}
|
||||||
const qualityValues = parseJson<QualityValue[]>(
|
const qualityValues = parseJson<QualityValue[]>(
|
||||||
form.get('qualityValues'),
|
form.get('qualityValues'),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const secondaryRanges = parseJson<SecondaryRange[]>(
|
|
||||||
form.get('secondaryRanges'),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
if (name.length < 2 || name.length > 100)
|
if (name.length < 2 || name.length > 100)
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
gearModError: 'Gear Mod names must be 2–100 characters.',
|
gearModError: 'Gear Mod names must be 2–100 characters.',
|
||||||
@@ -311,30 +331,6 @@ export const actions: Actions = {
|
|||||||
'Configure valid primary Level 1 and Level 20 ranges for all four qualities.',
|
'Configure valid primary Level 1 and Level 20 ranges for all four qualities.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
secondaryRanges.length < 2 ||
|
|
||||||
secondaryRanges.length > 50 ||
|
|
||||||
new Set(secondaryRanges.map((range) => range.statId)).size !==
|
|
||||||
secondaryRanges.length ||
|
|
||||||
secondaryRanges.some(
|
|
||||||
(range) =>
|
|
||||||
!range.statId ||
|
|
||||||
qualities.some((quality) => {
|
|
||||||
const values = range.values?.[quality];
|
|
||||||
return (
|
|
||||||
!values ||
|
|
||||||
!Number.isFinite(Number(values.min)) ||
|
|
||||||
!Number.isFinite(Number(values.max)) ||
|
|
||||||
Number(values.max) < Number(values.min)
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return fail(400, {
|
|
||||||
gearModError:
|
|
||||||
'Configure at least two unique possible secondary attributes with valid ranges for every quality.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
if (
|
if (
|
||||||
await (
|
await (
|
||||||
@@ -347,15 +343,6 @@ export const actions: Actions = {
|
|||||||
gearModError: 'That Gear Mod name is already registered.',
|
gearModError: 'That Gear Mod name is already registered.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const statIds = secondaryRanges.map((range) => range.statId);
|
|
||||||
const validStats = await (
|
|
||||||
await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes
|
|
||||||
WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)
|
|
||||||
).get(...statIds);
|
|
||||||
if (Number(validStats?.count) !== statIds.length)
|
|
||||||
return fail(400, {
|
|
||||||
gearModError: 'One or more selected attributes are unavailable.',
|
|
||||||
});
|
|
||||||
const modId =
|
const modId =
|
||||||
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||||||
const before = id ? await snapshot(id) : null;
|
const before = id ? await snapshot(id) : null;
|
||||||
@@ -394,29 +381,6 @@ export const actions: Actions = {
|
|||||||
row.primaryLevel20Min,
|
row.primaryLevel20Min,
|
||||||
row.primaryLevel20Max,
|
row.primaryLevel20Max,
|
||||||
);
|
);
|
||||||
await (
|
|
||||||
await database.prepare(
|
|
||||||
'DELETE FROM gear_mod_secondary_options WHERE gear_mod_id=?',
|
|
||||||
)
|
|
||||||
).run(modId);
|
|
||||||
for (const range of secondaryRanges) {
|
|
||||||
await (
|
|
||||||
await database.prepare(
|
|
||||||
'INSERT INTO gear_mod_secondary_options(gear_mod_id,stat_attribute_id) VALUES(?,?)',
|
|
||||||
)
|
|
||||||
).run(modId, range.statId);
|
|
||||||
for (const quality of qualities)
|
|
||||||
await (
|
|
||||||
await database.prepare(`INSERT INTO gear_mod_secondary_quality_values
|
|
||||||
(gear_mod_id,stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?,?)`)
|
|
||||||
).run(
|
|
||||||
modId,
|
|
||||||
range.statId,
|
|
||||||
quality,
|
|
||||||
range.values[quality].min,
|
|
||||||
range.values[quality].max,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await database.exec('COMMIT');
|
await database.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await database.exec('ROLLBACK');
|
await database.exec('ROLLBACK');
|
||||||
|
|||||||
@@ -5,26 +5,21 @@
|
|||||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||||
import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte';
|
import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte';
|
||||||
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
||||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
|
||||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||||
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
|
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
|
||||||
|
import ModSecondaryPoolEditor from '$lib/components/ModSecondaryPoolEditor.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
|
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
|
||||||
const qualityRow = (quality: string) => ({
|
const qualityRow = (quality: string) => ({
|
||||||
quality, primaryLevel1Min: 0, primaryLevel1Max: 0, primaryLevel20Min: 0, primaryLevel20Max: 0
|
quality, primaryLevel1Min: 0, primaryLevel1Max: 0, primaryLevel20Min: 0, primaryLevel20Max: 0
|
||||||
});
|
});
|
||||||
const blankSecondary = () => ({
|
|
||||||
statId: '',
|
|
||||||
values: Object.fromEntries(data.qualities.map((quality: string) => [quality, { min: 0, max: 0 }]))
|
|
||||||
});
|
|
||||||
const blank = () => ({
|
const blank = () => ({
|
||||||
id: '',
|
id: '',
|
||||||
name: '',
|
name: '',
|
||||||
active: 1,
|
active: 1,
|
||||||
renderUrl: '',
|
renderUrl: '',
|
||||||
qualityValues: data.qualities.map(qualityRow),
|
qualityValues: data.qualities.map(qualityRow)
|
||||||
secondaryRanges: [blankSecondary(), blankSecondary()]
|
|
||||||
});
|
});
|
||||||
let editing = $state<any | null>(null);
|
let editing = $state<any | null>(null);
|
||||||
let levelPreview = $state(1);
|
let levelPreview = $state(1);
|
||||||
@@ -32,7 +27,6 @@
|
|||||||
let handledResultToken = $state('');
|
let handledResultToken = $state('');
|
||||||
let renderResetToken = $state(0);
|
let renderResetToken = $state(0);
|
||||||
let openedRequestedItem = $state(false);
|
let openedRequestedItem = $state(false);
|
||||||
const stat = (id: string) => data.stats.find((item: any) => item.id === id);
|
|
||||||
const previewRow = $derived(editing?.qualityValues.find((row: any) => row.quality === qualityPreview));
|
const previewRow = $derived(editing?.qualityValues.find((row: any) => row.quality === qualityPreview));
|
||||||
const interpolate = (start: unknown, end: unknown) => Number(start) + (Number(end) - Number(start)) * (levelPreview - 1) / 19;
|
const interpolate = (start: unknown, end: unknown) => Number(start) + (Number(end) - Number(start)) * (levelPreview - 1) / 19;
|
||||||
const display = (value: unknown) => Number.isFinite(Number(value))
|
const display = (value: unknown) => Number.isFinite(Number(value))
|
||||||
@@ -44,22 +38,11 @@
|
|||||||
qualityValues: data.qualities.map((quality: string) => ({
|
qualityValues: data.qualities.map((quality: string) => ({
|
||||||
...qualityRow(quality),
|
...qualityRow(quality),
|
||||||
...(item.qualityValues.find((row: any) => row.quality === quality) ?? {})
|
...(item.qualityValues.find((row: any) => row.quality === quality) ?? {})
|
||||||
})),
|
}))
|
||||||
secondaryRanges: item.secondaryRanges.length
|
|
||||||
? item.secondaryRanges.map((range: any) => ({ statId: range.statId, values: structuredClone(range.values) }))
|
|
||||||
: [blankSecondary(), blankSecondary()]
|
|
||||||
};
|
};
|
||||||
levelPreview = 1;
|
levelPreview = 1;
|
||||||
qualityPreview = 'High-end';
|
qualityPreview = 'High-end';
|
||||||
};
|
};
|
||||||
const addSecondary = () => {
|
|
||||||
if (editing.secondaryRanges.length < 50) editing.secondaryRanges = [...editing.secondaryRanges, blankSecondary()];
|
|
||||||
};
|
|
||||||
const removeSecondary = (index: number) => {
|
|
||||||
if (editing.secondaryRanges.length > 2) {
|
|
||||||
editing.secondaryRanges = editing.secondaryRanges.filter((_: any, itemIndex: number) => itemIndex !== index);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!openedRequestedItem && data.requestedItemId) {
|
if (!openedRequestedItem && data.requestedItemId) {
|
||||||
const requested = data.mods.find((item: any) => item.id === data.requestedItemId);
|
const requested = data.mods.find((item: any) => item.id === data.requestedItemId);
|
||||||
@@ -87,6 +70,8 @@
|
|||||||
<button class="primary" onclick={() => editing = blank()}>ADD GEAR MOD <span>+</span></button>
|
<button class="primary" onclick={() => editing = blank()}>ADD GEAR MOD <span>+</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ModSecondaryPoolEditor kind="Gear Mod" pool={data.secondaryPool} stats={data.stats} error={form?.gearModSecondaryPoolError} saved={form?.gearModSecondaryPoolSaved} resultToken={form?.resultToken}/>
|
||||||
|
|
||||||
<KpiFilters active={data.filter} items={[
|
<KpiFilters active={data.filter} items={[
|
||||||
{value:'',label:'TOTAL MODS',count:data.summary.total,description:'Show all Gear Mod definitions'},
|
{value:'',label:'TOTAL MODS',count:data.summary.total,description:'Show all Gear Mod definitions'},
|
||||||
{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to the builder'},
|
{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to the builder'},
|
||||||
@@ -103,7 +88,6 @@
|
|||||||
<PendingForm action="?/save" enctype="multipart/form-data">
|
<PendingForm action="?/save" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="id" value={editing.id}/>
|
<input type="hidden" name="id" value={editing.id}/>
|
||||||
<input type="hidden" name="qualityValues" value={JSON.stringify(editing.qualityValues)}/>
|
<input type="hidden" name="qualityValues" value={JSON.stringify(editing.qualityValues)}/>
|
||||||
<input type="hidden" name="secondaryRanges" value={JSON.stringify(editing.secondaryRanges)}/>
|
|
||||||
<div class="gear-mod-identity">
|
<div class="gear-mod-identity">
|
||||||
<label class="field">GEAR MOD NAME<input name="name" bind:value={editing.name} maxlength="100" required/></label>
|
<label class="field">GEAR MOD NAME<input name="name" bind:value={editing.name} maxlength="100" required/></label>
|
||||||
<div class="weapon-mod-primary-types gear-mod-primary-types">
|
<div class="weapon-mod-primary-types gear-mod-primary-types">
|
||||||
@@ -143,34 +127,12 @@
|
|||||||
{#if previewRow}
|
{#if previewRow}
|
||||||
<div class="weapon-mod-preview-values">
|
<div class="weapon-mod-preview-values">
|
||||||
<span><small>SELECTED PRIMARY · LEVEL {levelPreview}</small><b>{display(interpolate(previewRow.primaryLevel1Min,previewRow.primaryLevel20Min))}–{display(interpolate(previewRow.primaryLevel1Max,previewRow.primaryLevel20Max))}</b></span>
|
<span><small>SELECTED PRIMARY · LEVEL {levelPreview}</small><b>{display(interpolate(previewRow.primaryLevel1Min,previewRow.primaryLevel20Min))}–{display(interpolate(previewRow.primaryLevel1Max,previewRow.primaryLevel20Max))}</b></span>
|
||||||
<span><small>SECONDARY ATTRIBUTE POOL</small><b>{editing.secondaryRanges.length} OPTIONS</b></span>
|
<span><small>SHARED SECONDARY POOL</small><b>{data.secondaryPool.length} OPTIONS</b></span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="weapon-mod-secondary-section">
|
|
||||||
<div class="selection-head">
|
|
||||||
<div><span>SECONDARY ATTRIBUTE POOL</span><small>THE BUILDER SELECTS TWO DISTINCT ATTRIBUTES · EVERY OPTION REQUIRES A RANGE AT EACH QUALITY</small></div>
|
|
||||||
<button type="button" class="row-action" onclick={addSecondary} disabled={editing.secondaryRanges.length >= 50}>ADD ATTRIBUTE +</button>
|
|
||||||
</div>
|
|
||||||
<div class="weapon-mod-secondary-options">
|
|
||||||
{#each editing.secondaryRanges as range,index}
|
|
||||||
<div>
|
|
||||||
<strong>{String(index + 1).padStart(2,'0')}</strong>
|
|
||||||
<SearchableAssignment items={data.stats} mode="single" compact subjectLabel="POSSIBLE ATTRIBUTE" searchPlaceholder="Search attributes…" excludedIds={editing.secondaryRanges.filter((_: any,itemIndex: number) => itemIndex !== index).map((item: any) => item.statId)} bind:value={range.statId}/>
|
|
||||||
{#each data.qualities as quality}
|
|
||||||
<label>
|
|
||||||
<span class={qualityClass(quality)}>{quality.toUpperCase()}</span>
|
|
||||||
<div><input type="number" step="any" bind:value={range.values[quality].min} aria-label={`${quality} ${stat(range.statId)?.name ?? 'attribute'} minimum`} required/><i>–</i><input type="number" step="any" bind:value={range.values[quality].max} aria-label={`${quality} ${stat(range.statId)?.name ?? 'attribute'} maximum`} required/></div>
|
|
||||||
</label>
|
|
||||||
{/each}
|
|
||||||
<button type="button" aria-label={`Remove secondary attribute option ${index + 1}`} onclick={() => removeSecondary(index)} disabled={editing.secondaryRanges.length <= 2}>×</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{#if form?.gearModError}<p class="error">{form.gearModError}</p>{/if}
|
{#if form?.gearModError}<p class="error">{form.gearModError}</p>{/if}
|
||||||
<div class="form-actions split-actions">
|
<div class="form-actions split-actions">
|
||||||
<div>{#if editing.id}<button class="moderation-action danger" formaction={Number(editing.active) ? '?/archive' : '?/unarchive'} name="id" value={editing.id}>{Number(editing.active) ? 'ARCHIVE GEAR MOD' : 'UNARCHIVE GEAR MOD'}</button>{/if}</div>
|
<div>{#if editing.id}<button class="moderation-action danger" formaction={Number(editing.active) ? '?/archive' : '?/unarchive'} name="id" value={editing.id}>{Number(editing.active) ? 'ARCHIVE GEAR MOD' : 'UNARCHIVE GEAR MOD'}</button>{/if}</div>
|
||||||
@@ -186,12 +148,12 @@
|
|||||||
|
|
||||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||||
<div class="dashboard-table">
|
<div class="dashboard-table">
|
||||||
<div class="dashboard-row gear-mod-row table-heading"><SortableColumnHeader label="GEAR MOD" column="name" sort={data.sort}/><span>PRIMARY</span><SortableColumnHeader label="SECONDARIES" column="secondaries" sort={data.sort}/><span>QUALITY</span><span>LEVELS</span><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
<div class="dashboard-row gear-mod-row table-heading"><SortableColumnHeader label="GEAR MOD" column="name" sort={data.sort}/><span>PRIMARY</span><span>SECONDARIES</span><span>QUALITY</span><span>LEVELS</span><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
||||||
{#each data.mods as item}
|
{#each data.mods as item}
|
||||||
<div class="dashboard-row gear-mod-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}>
|
<div class="dashboard-row gear-mod-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}>
|
||||||
<div class="registry-render-identity">{#if item.renderUrl}<img class="registry-render" src={item.renderUrl} alt=""/>{/if}<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.id.slice(0,8).toUpperCase()}</small></div></div>
|
<div class="registry-render-identity">{#if item.renderUrl}<img class="registry-render" src={item.renderUrl} alt=""/>{/if}<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.id.slice(0,8).toUpperCase()}</small></div></div>
|
||||||
<span><span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span>5 OPTIONS</span>
|
<span><span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span>5 OPTIONS</span>
|
||||||
<span>{item.secondaryRanges.length} POSSIBLE<small> · SELECT 2</small></span>
|
<span>{data.secondaryPool.length} SHARED<small> · 0 / 1 / 2 / 2</small></span>
|
||||||
<span>STANDARD → HIGH-END</span>
|
<span>STANDARD → HIGH-END</span>
|
||||||
<span>01 → 20</span>
|
<span>01 → 20</span>
|
||||||
<b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>
|
<b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
audits: [] as Array<Record<string, unknown>>,
|
||||||
|
database: {
|
||||||
|
exec: vi.fn(async () => undefined),
|
||||||
|
prepare: vi.fn(async () => ({
|
||||||
|
get: vi.fn(async (...statIds: unknown[]) => ({ count: statIds.length })),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
getModSecondaryPool: vi.fn(),
|
||||||
|
replaceModSecondaryPool: vi.fn(),
|
||||||
|
requirePermission: vi.fn(async (user) => user),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/auth/session', () => ({
|
||||||
|
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/auth/authorization', () => ({
|
||||||
|
requireDataView: vi.fn(async (user) => user),
|
||||||
|
requirePermission: mocks.requirePermission,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/database', () => ({
|
||||||
|
getDatabase: vi.fn(async () => mocks.database),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/audit', () => ({
|
||||||
|
auditRecord: vi.fn(),
|
||||||
|
recordIsacAudit: vi.fn(async (event) => {
|
||||||
|
mocks.audits.push(event);
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/data-moderation', () => ({
|
||||||
|
getPendingEntities: vi.fn(async () => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/managed-images', () => ({
|
||||||
|
applyGameDataRender: vi.fn(),
|
||||||
|
ManagedImageValidationError: class extends Error {},
|
||||||
|
managedImageUrl: vi.fn(() => ''),
|
||||||
|
processManagedImage: vi.fn(),
|
||||||
|
readGameDataRenderMutation: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/mod-secondary-pools', () => ({
|
||||||
|
getModSecondaryPool: mocks.getModSecondaryPool,
|
||||||
|
replaceModSecondaryPool: mocks.replaceModSecondaryPool,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { actions } from './+page.server';
|
||||||
|
|
||||||
|
const endpoints = (
|
||||||
|
level1Min: number,
|
||||||
|
level1Max: number,
|
||||||
|
level20Min: number,
|
||||||
|
level20Max: number,
|
||||||
|
) => ({ level1Min, level1Max, level20Min, level20Max });
|
||||||
|
|
||||||
|
const pool = (suffix: string, offset = 0) => [
|
||||||
|
{
|
||||||
|
statId: `critical-hit-chance-${suffix}`,
|
||||||
|
values: {
|
||||||
|
Specialized: endpoints(1 + offset, 2 + offset, 3 + offset, 4 + offset),
|
||||||
|
Superior: endpoints(2 + offset, 3 + offset, 4 + offset, 5 + offset),
|
||||||
|
'High-end': endpoints(3 + offset, 4 + offset, 5 + offset, 6 + offset),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
statId: `critical-hit-damage-${suffix}`,
|
||||||
|
values: {
|
||||||
|
Specialized: endpoints(4 + offset, 5 + offset, 6 + offset, 7 + offset),
|
||||||
|
Superior: endpoints(5 + offset, 6 + offset, 7 + offset, 8 + offset),
|
||||||
|
'High-end': endpoints(6 + offset, 7 + offset, 8 + offset, 9 + offset),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('Gear Mod shared secondary pool action', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.audits.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('audits the actor, stable pool target, and complete before/after evidence', async () => {
|
||||||
|
const before = pool('before');
|
||||||
|
const after = pool('after', 1);
|
||||||
|
mocks.getModSecondaryPool
|
||||||
|
.mockResolvedValueOnce(before)
|
||||||
|
.mockResolvedValueOnce(after);
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.set('id', 'gear-mod-secondary-pool');
|
||||||
|
form.set('secondaryRanges', JSON.stringify(after));
|
||||||
|
|
||||||
|
const result = await actions.saveSecondaryPool?.({
|
||||||
|
cookies: {} as never,
|
||||||
|
request: new Request(
|
||||||
|
'http://localhost/isac/gearmods?/saveSecondaryPool',
|
||||||
|
{ method: 'POST', body: form },
|
||||||
|
),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
gearModSecondaryPoolSaved: true,
|
||||||
|
gearModSecondaryPoolMode: 'edit',
|
||||||
|
resultToken: expect.any(String),
|
||||||
|
});
|
||||||
|
expect(mocks.requirePermission).toHaveBeenCalledWith(
|
||||||
|
{ id: 'operator-1' },
|
||||||
|
'data.manage',
|
||||||
|
);
|
||||||
|
expect(mocks.replaceModSecondaryPool).toHaveBeenCalledWith(
|
||||||
|
mocks.database,
|
||||||
|
'gear',
|
||||||
|
after,
|
||||||
|
);
|
||||||
|
expect(mocks.audits).toEqual([
|
||||||
|
{
|
||||||
|
actorId: 'operator-1',
|
||||||
|
category: 'game_data',
|
||||||
|
action: 'gear_mod_secondary_pool.updated',
|
||||||
|
entityId: 'gear-mod-secondary-pool',
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
database: mocks.database,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(mocks.database.exec).toHaveBeenNthCalledWith(1, 'BEGIN IMMEDIATE');
|
||||||
|
expect(mocks.database.exec).toHaveBeenNthCalledWith(2, 'COMMIT');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,7 +9,7 @@ export const load: PageServerLoad = async ({ cookies }) => {
|
|||||||
const attributes = await (
|
const attributes = await (
|
||||||
await database.prepare(`
|
await database.prepare(`
|
||||||
SELECT id,name,description,unit,type,category,calculation_type,cap_value,
|
SELECT id,name,description,unit,type,category,calculation_type,cap_value,
|
||||||
gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max
|
gear_min,gear_max,weapon_min,weapon_max
|
||||||
FROM stat_attributes
|
FROM stat_attributes
|
||||||
WHERE active=1
|
WHERE active=1
|
||||||
ORDER BY category COLLATE NOCASE,type COLLATE NOCASE,name COLLATE NOCASE
|
ORDER BY category COLLATE NOCASE,type COLLATE NOCASE,name COLLATE NOCASE
|
||||||
|
|||||||
@@ -19,8 +19,6 @@
|
|||||||
gear_max?: number | null;
|
gear_max?: number | null;
|
||||||
weapon_min?: number | null;
|
weapon_min?: number | null;
|
||||||
weapon_max?: number | null;
|
weapon_max?: number | null;
|
||||||
skill_chip_min?: number | null;
|
|
||||||
skill_chip_max?: number | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
@@ -131,7 +129,6 @@
|
|||||||
const sourceRanges = (attribute: AttributeVariable) => [
|
const sourceRanges = (attribute: AttributeVariable) => [
|
||||||
{ label: 'GEAR', value: valueRange(attribute.gear_min, attribute.gear_max) },
|
{ label: 'GEAR', value: valueRange(attribute.gear_min, attribute.gear_max) },
|
||||||
{ label: 'WEAPON', value: valueRange(attribute.weapon_min, attribute.weapon_max) },
|
{ label: 'WEAPON', value: valueRange(attribute.weapon_min, attribute.weapon_max) },
|
||||||
{ label: 'SKILL CHIP', value: valueRange(attribute.skill_chip_min, attribute.skill_chip_max) }
|
|
||||||
].filter((source) => source.value);
|
].filter((source) => source.value);
|
||||||
|
|
||||||
const superiorFirepower = [
|
const superiorFirepower = [
|
||||||
|
|||||||
@@ -35,27 +35,15 @@ import {
|
|||||||
saveConditionalRuleInTransaction,
|
saveConditionalRuleInTransaction,
|
||||||
} from '$lib/server/conditional-effects';
|
} from '$lib/server/conditional-effects';
|
||||||
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
|
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
|
||||||
|
import {
|
||||||
|
getOsProtocolProfileAttributeIds,
|
||||||
|
type OsProtocolAttributeType,
|
||||||
|
} from '$lib/server/os-protocol-profiles';
|
||||||
|
import type { Database } from '@tursodatabase/database';
|
||||||
|
|
||||||
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
|
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
|
||||||
const attributeTypes = ['Firepower', 'Toughness', 'Engineering'];
|
const attributeTypes = ['Firepower', 'Toughness', 'Engineering'];
|
||||||
const secondaryRoles = ['Core', 'Damage', 'Health'];
|
const secondaryRoles = ['Core', 'Damage', 'Health'];
|
||||||
const expectedSecondaryAttributes: Record<string, Record<string, string>> = {
|
|
||||||
Firepower: {
|
|
||||||
Core: 'Firepower',
|
|
||||||
Damage: 'All Weapon Damage',
|
|
||||||
Health: 'Health',
|
|
||||||
},
|
|
||||||
Toughness: {
|
|
||||||
Core: 'Toughness',
|
|
||||||
Damage: 'All Weapon Damage',
|
|
||||||
Health: 'Health',
|
|
||||||
},
|
|
||||||
Engineering: {
|
|
||||||
Core: 'Engineering',
|
|
||||||
Damage: 'Skill Damage',
|
|
||||||
Health: 'Health',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderAuditMetadata = (render: Record<string, any> | null | undefined) =>
|
const renderAuditMetadata = (render: Record<string, any> | null | undefined) =>
|
||||||
render
|
render
|
||||||
@@ -88,15 +76,15 @@ const descriptionOutcomes = (
|
|||||||
Number(left.descriptionSlot) - Number(right.descriptionSlot),
|
Number(left.descriptionSlot) - Number(right.descriptionSlot),
|
||||||
);
|
);
|
||||||
|
|
||||||
async function protocolSnapshot(id: string) {
|
async function protocolSnapshot(id: string, suppliedDatabase?: Database) {
|
||||||
const database = await getDatabase();
|
const database = suppliedDatabase ?? (await getDatabase());
|
||||||
const talent = await (
|
const talent = await (
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
'SELECT * FROM os_protocol_talents WHERE protocol_id=?',
|
'SELECT * FROM os_protocol_talents WHERE protocol_id=?',
|
||||||
)
|
)
|
||||||
).get(id);
|
).get(id);
|
||||||
return {
|
return {
|
||||||
record: await auditRecord('os_protocols', id),
|
record: await auditRecord('os_protocols', id, database),
|
||||||
icon: await gameDataIconMetadata(database, 'os_protocol', id),
|
icon: await gameDataIconMetadata(database, 'os_protocol', id),
|
||||||
render: renderAuditMetadata(
|
render: renderAuditMetadata(
|
||||||
await (
|
await (
|
||||||
@@ -129,8 +117,8 @@ async function protocolSnapshot(id: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function profileSnapshot(id: string) {
|
async function profileSnapshot(id: string, suppliedDatabase?: Database) {
|
||||||
const database = await getDatabase();
|
const database = suppliedDatabase ?? (await getDatabase());
|
||||||
return {
|
return {
|
||||||
record:
|
record:
|
||||||
(await (
|
(await (
|
||||||
@@ -292,6 +280,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
'attributeType',
|
'attributeType',
|
||||||
'primaryStatId',
|
'primaryStatId',
|
||||||
'primaryBaseValue',
|
'primaryBaseValue',
|
||||||
|
'hasTalent',
|
||||||
'talentName',
|
'talentName',
|
||||||
'descriptionTemplate',
|
'descriptionTemplate',
|
||||||
'conditionalRule',
|
'conditionalRule',
|
||||||
@@ -341,17 +330,20 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
profile.quality === protocol.quality &&
|
profile.quality === protocol.quality &&
|
||||||
profile.primary_attribute_type === protocol.attributeType,
|
profile.primary_attribute_type === protocol.attributeType,
|
||||||
) ?? null,
|
) ?? null,
|
||||||
talent: {
|
talent:
|
||||||
name: protocol.talentName,
|
protocol.hasTalent === '1'
|
||||||
description_template: protocol.descriptionTemplate,
|
? {
|
||||||
conditionalRule: (() => {
|
name: protocol.talentName,
|
||||||
try {
|
description_template: protocol.descriptionTemplate,
|
||||||
return JSON.parse(String(protocol.conditionalRule ?? 'null'));
|
conditionalRule: (() => {
|
||||||
} catch {
|
try {
|
||||||
return null;
|
return JSON.parse(String(protocol.conditionalRule ?? 'null'));
|
||||||
}
|
} catch {
|
||||||
})(),
|
return null;
|
||||||
},
|
}
|
||||||
|
})(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
}));
|
}));
|
||||||
const configuredProtocols = await Promise.all(
|
const configuredProtocols = await Promise.all(
|
||||||
protocols.map(async (protocol) => {
|
protocols.map(async (protocol) => {
|
||||||
@@ -450,27 +442,26 @@ export const actions: Actions = {
|
|||||||
profileError: 'Choose a valid quality and attribute type.',
|
profileError: 'Choose a valid quality and attribute type.',
|
||||||
});
|
});
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const expected = expectedSecondaryAttributes[attributeType];
|
const attributeIds = await getOsProtocolProfileAttributeIds(
|
||||||
const statRows = await (
|
database,
|
||||||
await database.prepare(
|
attributeType as OsProtocolAttributeType,
|
||||||
`SELECT id,name FROM stat_attributes WHERE active=1 AND name IN (?,?,?) COLLATE NOCASE`,
|
|
||||||
)
|
|
||||||
).all(expected.Core, expected.Damage, expected.Health);
|
|
||||||
const statByName = new Map(
|
|
||||||
statRows.map((stat) => [
|
|
||||||
String(stat.name).toLowerCase(),
|
|
||||||
String(stat.id),
|
|
||||||
]),
|
|
||||||
);
|
);
|
||||||
if (statRows.length !== 3)
|
if (Object.values(attributeIds).some((id) => !id))
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
profileError: `Create active stats named ${Object.values(expected).join(', ')} before configuring this profile.`,
|
profileError:
|
||||||
|
'Create the required active core, damage, and Health Attributes before configuring this profile.',
|
||||||
});
|
});
|
||||||
const effects = secondaryRoles.map((role) => ({
|
const effects = secondaryRoles.map((role) => {
|
||||||
role,
|
const rawValue = form.get(`value${role}`);
|
||||||
statId: statByName.get(expected[role].toLowerCase()) ?? '',
|
return {
|
||||||
value: Number(form.get(`value${role}`)),
|
role,
|
||||||
}));
|
statId: attributeIds[role as keyof typeof attributeIds] ?? '',
|
||||||
|
value:
|
||||||
|
typeof rawValue === 'string' && rawValue.trim()
|
||||||
|
? Number(rawValue)
|
||||||
|
: Number.NaN,
|
||||||
|
};
|
||||||
|
});
|
||||||
if (
|
if (
|
||||||
effects.some(
|
effects.some(
|
||||||
(effect) =>
|
(effect) =>
|
||||||
@@ -481,47 +472,58 @@ export const actions: Actions = {
|
|||||||
profileError:
|
profileError:
|
||||||
'Every shared secondary attribute requires a non-negative value.',
|
'Every shared secondary attribute requires a non-negative value.',
|
||||||
});
|
});
|
||||||
const existing = await (
|
const saveProfile = database.transactionAsync(async (transaction) => {
|
||||||
await database.prepare(
|
const tx = transaction as unknown as Database;
|
||||||
'SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?',
|
const existing = await (
|
||||||
)
|
await tx.prepare(
|
||||||
).get(quality, attributeType);
|
'SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?',
|
||||||
const profileId = String(existing?.id ?? randomUUID()),
|
|
||||||
now = new Date().toISOString();
|
|
||||||
const before = existing ? await profileSnapshot(profileId) : null;
|
|
||||||
if (existing)
|
|
||||||
await (
|
|
||||||
await database.prepare(
|
|
||||||
'UPDATE os_protocol_secondary_profiles SET updated_at=? WHERE id=?',
|
|
||||||
)
|
)
|
||||||
).run(now, profileId);
|
).get(quality, attributeType);
|
||||||
else
|
const profileId = String(existing?.id ?? randomUUID());
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const before = existing ? await profileSnapshot(profileId, tx) : null;
|
||||||
|
if (existing)
|
||||||
|
await (
|
||||||
|
await tx.prepare(
|
||||||
|
'UPDATE os_protocol_secondary_profiles SET updated_at=? WHERE id=?',
|
||||||
|
)
|
||||||
|
).run(now, profileId);
|
||||||
|
else
|
||||||
|
await (
|
||||||
|
await tx.prepare(
|
||||||
|
'INSERT INTO os_protocol_secondary_profiles(id,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?)',
|
||||||
|
)
|
||||||
|
).run(profileId, quality, attributeType, now, now);
|
||||||
await (
|
await (
|
||||||
await database.prepare(
|
await tx.prepare(
|
||||||
'INSERT INTO os_protocol_secondary_profiles(id,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?)',
|
'DELETE FROM os_protocol_secondary_effects WHERE profile_id=?',
|
||||||
)
|
)
|
||||||
).run(profileId, quality, attributeType, now, now);
|
).run(profileId);
|
||||||
await (
|
for (const effect of effects)
|
||||||
await database.prepare(
|
await (
|
||||||
'DELETE FROM os_protocol_secondary_effects WHERE profile_id=?',
|
await tx.prepare(
|
||||||
)
|
'INSERT INTO os_protocol_secondary_effects(id,profile_id,effect_role,stat_attribute_id,value) VALUES(?,?,?,?,?)',
|
||||||
).run(profileId);
|
)
|
||||||
for (const effect of effects)
|
).run(
|
||||||
await (
|
randomUUID(),
|
||||||
await database.prepare(
|
profileId,
|
||||||
'INSERT INTO os_protocol_secondary_effects(id,profile_id,effect_role,stat_attribute_id,value) VALUES(?,?,?,?,?)',
|
effect.role,
|
||||||
)
|
effect.statId,
|
||||||
).run(randomUUID(), profileId, effect.role, effect.statId, effect.value);
|
effect.value,
|
||||||
await recordIsacAudit({
|
);
|
||||||
actorId: operator.id,
|
await recordIsacAudit({
|
||||||
category: 'game_data',
|
actorId: operator.id,
|
||||||
action: existing
|
category: 'game_data',
|
||||||
? 'os_protocol_profile.updated'
|
action: existing
|
||||||
: 'os_protocol_profile.created',
|
? 'os_protocol_profile.updated'
|
||||||
entityId: profileId,
|
: 'os_protocol_profile.created',
|
||||||
before,
|
entityId: profileId,
|
||||||
after: await profileSnapshot(profileId),
|
before,
|
||||||
|
after: await profileSnapshot(profileId, tx),
|
||||||
|
database: tx,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
await saveProfile.immediate();
|
||||||
return { profileSaved: true, resultToken: randomUUID() };
|
return { profileSaved: true, resultToken: randomUUID() };
|
||||||
},
|
},
|
||||||
save: async ({ request, cookies }) => {
|
save: async ({ request, cookies }) => {
|
||||||
@@ -559,7 +561,12 @@ export const actions: Actions = {
|
|||||||
const quality = String(form.get('quality') ?? '');
|
const quality = String(form.get('quality') ?? '');
|
||||||
const attributeType = String(form.get('attributeType') ?? '');
|
const attributeType = String(form.get('attributeType') ?? '');
|
||||||
const primaryStatId = String(form.get('primaryStatId') ?? '');
|
const primaryStatId = String(form.get('primaryStatId') ?? '');
|
||||||
const primaryBaseValue = Number(form.get('primaryBaseValue'));
|
const primaryBaseValueRaw = form.get('primaryBaseValue');
|
||||||
|
const primaryBaseValue =
|
||||||
|
typeof primaryBaseValueRaw === 'string' && primaryBaseValueRaw.trim()
|
||||||
|
? Number(primaryBaseValueRaw)
|
||||||
|
: Number.NaN;
|
||||||
|
const hasTalent = form.get('hasTalent') === '1';
|
||||||
const talentName = String(form.get('talentName') ?? '')
|
const talentName = String(form.get('talentName') ?? '')
|
||||||
.normalize('NFKC')
|
.normalize('NFKC')
|
||||||
.trim();
|
.trim();
|
||||||
@@ -567,22 +574,27 @@ export const actions: Actions = {
|
|||||||
.normalize('NFKC')
|
.normalize('NFKC')
|
||||||
.trim();
|
.trim();
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
let conditionalRule;
|
let conditionalRule: ReturnType<typeof parseConditionalRulePayload> | null =
|
||||||
|
null;
|
||||||
try {
|
try {
|
||||||
const conditionalRuleRaw = form.get('conditionalRule');
|
if (hasTalent) {
|
||||||
const activeStats = await (
|
const conditionalRuleRaw = form.get('conditionalRule');
|
||||||
await database.prepare('SELECT id FROM stat_attributes WHERE active=1')
|
const activeStats = await (
|
||||||
).all();
|
await database.prepare(
|
||||||
conditionalRule = parseConditionalRulePayload(
|
'SELECT id FROM stat_attributes WHERE active=1',
|
||||||
conditionalRuleRaw,
|
)
|
||||||
[{ key: 'base', variantKey: 'base' }],
|
).all();
|
||||||
{
|
conditionalRule = parseConditionalRulePayload(
|
||||||
allowedAttributeIds: new Set(
|
conditionalRuleRaw,
|
||||||
activeStats.map((row) => String(row.id)),
|
[{ key: 'base', variantKey: 'base' }],
|
||||||
),
|
{
|
||||||
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
|
allowedAttributeIds: new Set(
|
||||||
},
|
activeStats.map((row) => String(row.id)),
|
||||||
);
|
),
|
||||||
|
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
protocolError:
|
protocolError:
|
||||||
@@ -608,29 +620,39 @@ export const actions: Actions = {
|
|||||||
protocolError:
|
protocolError:
|
||||||
'Choose the protocol’s unique primary stat and a non-negative level-1 base value.',
|
'Choose the protocol’s unique primary stat and a non-negative level-1 base value.',
|
||||||
});
|
});
|
||||||
const outcomes = mechanicOutcomes(conditionalRule);
|
const outcomes = conditionalRule ? mechanicOutcomes(conditionalRule) : [];
|
||||||
const numberedOutcomes = descriptionOutcomes(conditionalRule);
|
const numberedOutcomes = conditionalRule
|
||||||
|
? descriptionOutcomes(conditionalRule)
|
||||||
|
: [];
|
||||||
const durationSeconds =
|
const durationSeconds =
|
||||||
conditionalRule.pools.find((pool) => pool.expiresAfterSeconds)
|
conditionalRule?.pools.find((pool) => pool.expiresAfterSeconds)
|
||||||
?.expiresAfterSeconds ?? null;
|
?.expiresAfterSeconds ?? null;
|
||||||
const maxStacks = conditionalRule.pools.length
|
const maxStacks = conditionalRule?.pools.length
|
||||||
? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1))
|
? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1))
|
||||||
: null;
|
: null;
|
||||||
|
const requiresCompleteMechanic =
|
||||||
|
hasTalent && conditionalRule?.simulationReadiness !== 'captured_only';
|
||||||
if (
|
if (
|
||||||
talentName.length < 2 ||
|
hasTalent &&
|
||||||
talentName.length > 80 ||
|
(talentName.length < 2 || talentName.length > 80 || !descriptionTemplate)
|
||||||
!descriptionTemplate ||
|
|
||||||
!conditionalRule.clauses.length ||
|
|
||||||
!outcomes.length
|
|
||||||
)
|
)
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
protocolError:
|
protocolError:
|
||||||
'The unique talent requires a name, description template, mechanic branch, and at least one outcome.',
|
'The assigned Talent requires a name and description template.',
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
requiresCompleteMechanic &&
|
||||||
|
(!conditionalRule?.clauses.length || !outcomes.length)
|
||||||
|
)
|
||||||
|
return fail(400, {
|
||||||
|
protocolError:
|
||||||
|
'Add a mechanic branch and outcome, or mark the Talent CAPTURE ONLY.',
|
||||||
});
|
});
|
||||||
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map(
|
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map(
|
||||||
(match) => Number(match[1]),
|
(match) => Number(match[1]),
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
|
requiresCompleteMechanic &&
|
||||||
placeholders.some(
|
placeholders.some(
|
||||||
(index) =>
|
(index) =>
|
||||||
!numberedOutcomes.some(
|
!numberedOutcomes.some(
|
||||||
@@ -642,23 +664,49 @@ export const actions: Actions = {
|
|||||||
protocolError:
|
protocolError:
|
||||||
'Description placeholders must reference configured numbered outcomes.',
|
'Description placeholders must reference configured numbered outcomes.',
|
||||||
});
|
});
|
||||||
if (descriptionTemplate.includes('{duration}') && durationSeconds === null)
|
if (
|
||||||
|
requiresCompleteMechanic &&
|
||||||
|
descriptionTemplate.includes('{duration}') &&
|
||||||
|
durationSeconds === null
|
||||||
|
)
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
protocolError:
|
protocolError:
|
||||||
'The description uses {duration}, but no expiring state pool is defined.',
|
'The description uses {duration}, but no expiring state pool is defined.',
|
||||||
});
|
});
|
||||||
if (descriptionTemplate.includes('{max_stacks}') && !maxStacks)
|
if (
|
||||||
|
requiresCompleteMechanic &&
|
||||||
|
descriptionTemplate.includes('{max_stacks}') &&
|
||||||
|
!maxStacks
|
||||||
|
)
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
protocolError:
|
protocolError:
|
||||||
'The description uses {max_stacks}, but no state pool maximum is defined.',
|
'The description uses {max_stacks}, but no state pool maximum is defined.',
|
||||||
});
|
});
|
||||||
if (
|
const profileAttributeIds = await getOsProtocolProfileAttributeIds(
|
||||||
!(await (
|
database,
|
||||||
|
attributeType as OsProtocolAttributeType,
|
||||||
|
);
|
||||||
|
const completeProfile =
|
||||||
|
Object.values(profileAttributeIds).every(Boolean) &&
|
||||||
|
(await (
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
'SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?',
|
`SELECT profile.id FROM os_protocol_secondary_profiles profile
|
||||||
|
JOIN os_protocol_secondary_effects effect ON effect.profile_id=profile.id
|
||||||
|
WHERE profile.quality=? AND profile.primary_attribute_type=?
|
||||||
|
GROUP BY profile.id
|
||||||
|
HAVING COUNT(effect.id)=3
|
||||||
|
AND SUM(CASE WHEN effect.effect_role='Core' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1
|
||||||
|
AND SUM(CASE WHEN effect.effect_role='Damage' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1
|
||||||
|
AND SUM(CASE WHEN effect.effect_role='Health' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1`,
|
||||||
)
|
)
|
||||||
).get(quality, attributeType))
|
).get(
|
||||||
)
|
quality,
|
||||||
|
attributeType,
|
||||||
|
profileAttributeIds.Core,
|
||||||
|
profileAttributeIds.Damage,
|
||||||
|
profileAttributeIds.Health,
|
||||||
|
));
|
||||||
|
if (!completeProfile)
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
protocolError:
|
protocolError:
|
||||||
'Configure the shared secondary profile for this quality and attribute type first.',
|
'Configure the shared secondary profile for this quality and attribute type first.',
|
||||||
@@ -679,11 +727,21 @@ export const actions: Actions = {
|
|||||||
).get(id)
|
).get(id)
|
||||||
: null;
|
: null;
|
||||||
if (
|
if (
|
||||||
await (
|
existingTalent &&
|
||||||
|
!hasTalent &&
|
||||||
|
form.get('confirmTalentRemoval') !== 'REMOVE'
|
||||||
|
)
|
||||||
|
return fail(400, {
|
||||||
|
protocolError:
|
||||||
|
'Confirm removal of the existing Talent and mechanic program.',
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
hasTalent &&
|
||||||
|
(await (
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
'SELECT id FROM os_protocol_talents WHERE name=? COLLATE NOCASE AND id<>?',
|
'SELECT id FROM os_protocol_talents WHERE name=? COLLATE NOCASE AND id<>?',
|
||||||
)
|
)
|
||||||
).get(talentName, existingTalent?.id ?? 'new')
|
).get(talentName, existingTalent?.id ?? 'new'))
|
||||||
)
|
)
|
||||||
return fail(409, {
|
return fail(409, {
|
||||||
protocolError: 'That protocol talent name is already in use.',
|
protocolError: 'That protocol talent name is already in use.',
|
||||||
@@ -701,71 +759,83 @@ export const actions: Actions = {
|
|||||||
return fail(400, {
|
return fail(400, {
|
||||||
protocolError: 'One or more selected attributes are unavailable.',
|
protocolError: 'One or more selected attributes are unavailable.',
|
||||||
});
|
});
|
||||||
const protocolId = id || randomUUID(),
|
const protocolId =
|
||||||
|
id || String(form.get('_proposedEntityId') ?? '') || randomUUID(),
|
||||||
talentId = String(existingTalent?.id ?? randomUUID()),
|
talentId = String(existingTalent?.id ?? randomUUID()),
|
||||||
now = new Date().toISOString();
|
now = new Date().toISOString();
|
||||||
const before = id ? await protocolSnapshot(id) : null;
|
const saveProtocol = database.transactionAsync(async (transaction) => {
|
||||||
if (id && !before?.record)
|
const tx = transaction as unknown as Database;
|
||||||
return fail(404, { protocolError: 'OS Protocol not found.' });
|
const before = id ? await protocolSnapshot(id, tx) : null;
|
||||||
await database.exec('BEGIN');
|
if (id && !before?.record)
|
||||||
try {
|
return fail(404, { protocolError: 'OS Protocol not found.' });
|
||||||
if (id) {
|
if (id) {
|
||||||
await (
|
await (
|
||||||
await database.prepare(
|
await tx.prepare(
|
||||||
'UPDATE os_protocols SET name=?,quality=?,primary_attribute_type=?,active=1,updated_at=? WHERE id=?',
|
'UPDATE os_protocols SET name=?,quality=?,primary_attribute_type=?,active=1,updated_at=? WHERE id=?',
|
||||||
)
|
)
|
||||||
).run(name, quality, attributeType, now, id);
|
).run(name, quality, attributeType, now, id);
|
||||||
} else
|
} else
|
||||||
await (
|
await (
|
||||||
await database.prepare(
|
await tx.prepare(
|
||||||
'INSERT INTO os_protocols(id,name,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?,?)',
|
'INSERT INTO os_protocols(id,name,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?,?)',
|
||||||
)
|
)
|
||||||
).run(protocolId, name, quality, attributeType, now, now);
|
).run(protocolId, name, quality, attributeType, now, now);
|
||||||
await (
|
await (
|
||||||
await database.prepare(`INSERT INTO os_protocol_primary_effects(id,protocol_id,stat_attribute_id,base_value) VALUES(?,?,?,?)
|
await tx.prepare(`INSERT INTO os_protocol_primary_effects(id,protocol_id,stat_attribute_id,base_value) VALUES(?,?,?,?)
|
||||||
ON CONFLICT(protocol_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,base_value=excluded.base_value`)
|
ON CONFLICT(protocol_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,base_value=excluded.base_value`)
|
||||||
).run(randomUUID(), protocolId, primaryStatId, primaryBaseValue);
|
).run(randomUUID(), protocolId, primaryStatId, primaryBaseValue);
|
||||||
await (
|
if (hasTalent && conditionalRule) {
|
||||||
await database.prepare(`INSERT INTO os_protocol_talents(id,protocol_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
|
|
||||||
ON CONFLICT(protocol_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)
|
|
||||||
).run(talentId, protocolId, talentName, descriptionTemplate, now, now);
|
|
||||||
await (
|
|
||||||
await database.prepare(
|
|
||||||
'DELETE FROM os_protocol_talent_effects WHERE talent_id=?',
|
|
||||||
)
|
|
||||||
).run(talentId);
|
|
||||||
let compatibilitySortOrder = 0;
|
|
||||||
for (const outcome of numberedOutcomes) {
|
|
||||||
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId)
|
|
||||||
continue;
|
|
||||||
compatibilitySortOrder += 1;
|
|
||||||
await (
|
await (
|
||||||
await database.prepare(
|
await tx.prepare(`INSERT INTO os_protocol_talents(id,protocol_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
|
||||||
'INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) VALUES(?,?,?,?,?)',
|
ON CONFLICT(protocol_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)
|
||||||
|
).run(talentId, protocolId, talentName, descriptionTemplate, now, now);
|
||||||
|
await (
|
||||||
|
await tx.prepare(
|
||||||
|
'DELETE FROM os_protocol_talent_effects WHERE talent_id=?',
|
||||||
)
|
)
|
||||||
).run(
|
).run(talentId);
|
||||||
randomUUID(),
|
let compatibilitySortOrder = 0;
|
||||||
|
for (const outcome of numberedOutcomes) {
|
||||||
|
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId)
|
||||||
|
continue;
|
||||||
|
compatibilitySortOrder += 1;
|
||||||
|
await (
|
||||||
|
await tx.prepare(
|
||||||
|
'INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) VALUES(?,?,?,?,?)',
|
||||||
|
)
|
||||||
|
).run(
|
||||||
|
randomUUID(),
|
||||||
|
talentId,
|
||||||
|
outcome.attributeId,
|
||||||
|
outcome.variants.base.value,
|
||||||
|
compatibilitySortOrder,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await saveConditionalRuleInTransaction(
|
||||||
|
tx,
|
||||||
|
'os_protocol_talent',
|
||||||
talentId,
|
talentId,
|
||||||
outcome.attributeId,
|
conditionalRule,
|
||||||
outcome.variants.base.value,
|
|
||||||
compatibilitySortOrder,
|
|
||||||
);
|
);
|
||||||
|
} else if (existingTalent) {
|
||||||
|
await (
|
||||||
|
await tx.prepare(
|
||||||
|
"DELETE FROM conditional_effect_rules WHERE source_type='os_protocol_talent' AND source_id=?",
|
||||||
|
)
|
||||||
|
).run(String(existingTalent.id));
|
||||||
|
await (
|
||||||
|
await tx.prepare('DELETE FROM os_protocol_talents WHERE id=?')
|
||||||
|
).run(String(existingTalent.id));
|
||||||
}
|
}
|
||||||
await saveConditionalRuleInTransaction(
|
|
||||||
database,
|
|
||||||
'os_protocol_talent',
|
|
||||||
talentId,
|
|
||||||
conditionalRule,
|
|
||||||
);
|
|
||||||
await applyIconMutation(
|
await applyIconMutation(
|
||||||
database,
|
tx,
|
||||||
'os_protocol',
|
'os_protocol',
|
||||||
protocolId,
|
protocolId,
|
||||||
iconMutation,
|
iconMutation,
|
||||||
operator.id,
|
operator.id,
|
||||||
);
|
);
|
||||||
await applyGameDataRender(
|
await applyGameDataRender(
|
||||||
database,
|
tx,
|
||||||
'os_protocol',
|
'os_protocol',
|
||||||
protocolId,
|
protocolId,
|
||||||
name,
|
name,
|
||||||
@@ -778,13 +848,13 @@ export const actions: Actions = {
|
|||||||
action: id ? 'os_protocol.updated' : 'os_protocol.created',
|
action: id ? 'os_protocol.updated' : 'os_protocol.created',
|
||||||
entityId: protocolId,
|
entityId: protocolId,
|
||||||
before,
|
before,
|
||||||
after: await protocolSnapshot(protocolId),
|
after: await protocolSnapshot(protocolId, tx),
|
||||||
|
database: tx,
|
||||||
});
|
});
|
||||||
await database.exec('COMMIT');
|
return null;
|
||||||
} catch (error) {
|
});
|
||||||
await database.exec('ROLLBACK');
|
const transactionResult = await saveProtocol.immediate();
|
||||||
throw error;
|
if (transactionResult) return transactionResult;
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
protocolSaved: true,
|
protocolSaved: true,
|
||||||
protocolMode: id ? 'edit' : 'add',
|
protocolMode: id ? 'edit' : 'add',
|
||||||
|
|||||||
@@ -29,15 +29,16 @@
|
|||||||
simulationReadiness: 'captured_only',
|
simulationReadiness: 'captured_only',
|
||||||
scenarioInputs: [],
|
scenarioInputs: [],
|
||||||
pools: [],
|
pools: [],
|
||||||
clauses: [{key:'primary',label:'Primary',event:'tick',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'self',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{base:{value:0}}}]}]
|
clauses: []
|
||||||
});
|
});
|
||||||
const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() });
|
const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() });
|
||||||
const blank = () => ({ id: '', name: '', iconUrl: '', renderUrl: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, talent: blankTalent() });
|
const blank = () => ({ id: '', name: '', iconUrl: '', renderUrl: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, hasTalent: false, talentOpen: false, talent: blankTalent() });
|
||||||
let editing = $state<any | null>(null);
|
let editing = $state<any | null>(null);
|
||||||
let profileEditing = $state<any | null>(null);
|
let profileEditing = $state<any | null>(null);
|
||||||
let talentUpgrade = $state(0);
|
let talentUpgrade = $state(0);
|
||||||
let protocolLevel = $state(1);
|
let protocolLevel = $state(1);
|
||||||
let handledResultToken = $state('');
|
let handledResultToken = $state('');
|
||||||
|
let handledErrorForm = $state<unknown>(null);
|
||||||
let iconResetToken = $state(0);
|
let iconResetToken = $state(0);
|
||||||
let renderResetToken = $state(0);
|
let renderResetToken = $state(0);
|
||||||
let openedRequestedItem = $state(false);
|
let openedRequestedItem = $state(false);
|
||||||
@@ -86,6 +87,8 @@
|
|||||||
editing = {
|
editing = {
|
||||||
...protocol,
|
...protocol,
|
||||||
primaryEffect: protocol.primaryEffect ? { ...protocol.primaryEffect } : { stat_attribute_id: '', base_value: '' },
|
primaryEffect: protocol.primaryEffect ? { ...protocol.primaryEffect } : { stat_attribute_id: '', base_value: '' },
|
||||||
|
hasTalent: Boolean(protocol.talent),
|
||||||
|
talentOpen: false,
|
||||||
talent: protocol.talent ? {
|
talent: protocol.talent ? {
|
||||||
...protocol.talent,
|
...protocol.talent,
|
||||||
conditionalRule: protocol.talent.conditionalRule ?? blankRule()
|
conditionalRule: protocol.talent.conditionalRule ?? blankRule()
|
||||||
@@ -115,6 +118,10 @@
|
|||||||
talentUpgrade = 0;
|
talentUpgrade = 0;
|
||||||
protocolLevel = 1;
|
protocolLevel = 1;
|
||||||
}
|
}
|
||||||
|
if (form?.protocolError && form !== handledErrorForm) {
|
||||||
|
handledErrorForm = form;
|
||||||
|
if (editing?.hasTalent) editing.talentOpen = true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -162,6 +169,7 @@
|
|||||||
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY PROTOCOL' : 'NEW PROTOCOL'}</p><h3>{editing.id ? editing.name : 'OS PROTOCOL DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
|
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY PROTOCOL' : 'NEW PROTOCOL'}</p><h3>{editing.id ? editing.name : 'OS PROTOCOL DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
|
||||||
<PendingForm action="?/save" enctype="multipart/form-data">
|
<PendingForm action="?/save" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="id" value={editing.id}/>
|
<input type="hidden" name="id" value={editing.id}/>
|
||||||
|
<input type="hidden" name="hasTalent" value={editing.hasTalent ? '1' : '0'}/>
|
||||||
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)} />
|
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)} />
|
||||||
<div class="protocol-identity-grid">
|
<div class="protocol-identity-grid">
|
||||||
<div class="icon-editor-identity"><IconUploadField currentUrl={editing.iconUrl} label="OS PROTOCOL ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="OS PROTOCOL RENDER" resetToken={renderResetToken}/><label class="field">PROTOCOL NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label></div>
|
<div class="icon-editor-identity"><IconUploadField currentUrl={editing.iconUrl} label="OS PROTOCOL ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="OS PROTOCOL RENDER" resetToken={renderResetToken}/><label class="field">PROTOCOL NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label></div>
|
||||||
@@ -178,21 +186,31 @@
|
|||||||
<div class="protocol-primary-editor"><div class="field"><span>PRIMARY STAT</span><SearchableAssignment items={data.stats} fieldName="primaryStatId" mode="single" compact subjectLabel="PRIMARY ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={editing.primaryEffect.stat_attribute_id}/></div><label class="field">LEVEL 1 BASE VALUE<input name="primaryBaseValue" type="number" step="any" min="0" bind:value={editing.primaryEffect.base_value} required/></label><div><span>LEVEL {protocolLevel} PREVIEW</span><b>{format(levelValue(editing.primaryEffect.base_value, protocolLevel), editing.primaryEffect.stat_attribute_id)}</b><small>LEVEL 50 · {format(levelValue(editing.primaryEffect.base_value, 50), editing.primaryEffect.stat_attribute_id)}</small></div></div>
|
<div class="protocol-primary-editor"><div class="field"><span>PRIMARY STAT</span><SearchableAssignment items={data.stats} fieldName="primaryStatId" mode="single" compact subjectLabel="PRIMARY ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={editing.primaryEffect.stat_attribute_id}/></div><label class="field">LEVEL 1 BASE VALUE<input name="primaryBaseValue" type="number" step="any" min="0" bind:value={editing.primaryEffect.base_value} required/></label><div><span>LEVEL {protocolLevel} PREVIEW</span><b>{format(levelValue(editing.primaryEffect.base_value, protocolLevel), editing.primaryEffect.stat_attribute_id)}</b><small>LEVEL 50 · {format(levelValue(editing.primaryEffect.base_value, 50), editing.primaryEffect.stat_attribute_id)}</small></div></div>
|
||||||
|
|
||||||
<section class="protocol-talent">
|
<section class="protocol-talent">
|
||||||
<div class="selection-head"><div><span>UNIQUE PROTOCOL TALENT</span><small>OWNED EXCLUSIVELY BY THIS PROTOCOL · FIVE LINEAR UPGRADES FOR MARKED VALUES</small></div><b>{editing.talent.id ? 'LINKED RECORD' : 'NEW RECORD'}</b></div>
|
<div class="selection-head"><div><span>UNIQUE PROTOCOL TALENT</span><small>OPTIONAL OWNED TALENT · FIVE LINEAR UPGRADES FOR MARKED VALUES</small></div><SegmentedChoice legend="TALENT ASSIGNMENT" name="talent-assignment" options={[{value:'0',label:'NOT ASSIGNED'},{value:'1',label:'ASSIGNED'}]} value={editing.hasTalent ? '1' : '0'} onchange={(values) => { editing.hasTalent = values[0] === '1'; if (editing.hasTalent) editing.talentOpen = true; }}/></div>
|
||||||
<div class="protocol-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label><label class="field">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={editing.talent.description_template} placeholder="While active, gain +{1} damage…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values and units.</small></label></div>
|
{#if editing.hasTalent}
|
||||||
<div class="protocol-upgrade-preview"><div class="diamond-control" role="group" aria-label="Preview talent upgrade level"><button type="button" class:active={talentUpgrade === 0} aria-pressed={talentUpgrade === 0} onclick={() => talentUpgrade = 0}>BASE</button>{#each [1, 2, 3, 4, 5] as upgrade}<button type="button" class:active={talentUpgrade >= upgrade} aria-label={`Preview talent at upgrade ${upgrade}`} aria-pressed={talentUpgrade === upgrade} onclick={() => talentUpgrade = upgrade}><span>◆</span><small>{upgrade}</small></button>{/each}</div><div><span>UPGRADE {talentUpgrade} / 5 · {talentUpgrade === 5 ? '2× SCALABLE VALUES' : `${Math.round((1 + talentUpgrade / 5) * 100)}% SCALABLE VALUES`}</span><p>{talentPreview(talentUpgrade) || 'Talent description preview'}</p></div></div>
|
<details class="modeling-disclosure protocol-talent-disclosure" bind:open={editing.talentOpen}>
|
||||||
<MechanicProgramEditor
|
<summary><span><b>{editing.talent.name || 'TALENT WORKSPACE'}</b><small>{previewOutcomes().length} PROTOCOL OUTCOME{previewOutcomes().length === 1 ? '' : 'S'} · {editing.talent.conditionalRule.simulationReadiness === 'captured_only' ? 'CAPTURE ONLY' : 'MODELED'}</small></span><strong>{editing.talentOpen ? 'COLLAPSE' : 'EXPAND'}</strong></summary>
|
||||||
attributes={data.stats}
|
<div class="modeling-content">
|
||||||
valueColumns={talentValueColumns}
|
<div class="protocol-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label><label class="field">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={editing.talent.description_template} placeholder="While active, gain +{1} damage…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values and units.</small></label></div>
|
||||||
idPrefix={`protocol-talent-${editing.talent.id || 'new'}`}
|
<div class="protocol-upgrade-preview"><div class="diamond-control" role="group" aria-label="Preview talent upgrade level"><button type="button" class:active={talentUpgrade === 0} aria-pressed={talentUpgrade === 0} onclick={() => talentUpgrade = 0}>BASE</button>{#each [1, 2, 3, 4, 5] as upgrade}<button type="button" class:active={talentUpgrade >= upgrade} aria-label={`Preview talent at upgrade ${upgrade}`} aria-pressed={talentUpgrade === upgrade} onclick={() => talentUpgrade = upgrade}><span>◆</span><small>{upgrade}</small></button>{/each}</div><div><span>UPGRADE {talentUpgrade} / 5 · {talentUpgrade === 5 ? '2× SCALABLE VALUES' : `${Math.round((1 + talentUpgrade / 5) * 100)}% SCALABLE VALUES`}</span><p>{talentPreview(talentUpgrade) || 'Talent description preview'}</p></div></div>
|
||||||
aiContext={{
|
<MechanicProgramEditor
|
||||||
sourceKind: 'os_protocol_talent',
|
attributes={data.stats}
|
||||||
sourceName: editing?.talent?.name ?? '',
|
valueColumns={talentValueColumns}
|
||||||
sourceDescription: editing?.talent?.description_template ?? ''
|
idPrefix={`protocol-talent-${editing.talent.id || 'new'}`}
|
||||||
}}
|
aiContext={{
|
||||||
allowProtocolUpgradeScaling
|
sourceKind: 'os_protocol_talent',
|
||||||
bind:rule={editing.talent.conditionalRule}
|
sourceName: editing?.talent?.name ?? '',
|
||||||
/>
|
sourceDescription: editing?.talent?.description_template ?? ''
|
||||||
|
}}
|
||||||
|
allowProtocolUpgradeScaling
|
||||||
|
bind:rule={editing.talent.conditionalRule}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{:else}
|
||||||
|
<p class="console-note">This OS Protocol has no Talent. Its primary and inherited secondary Attributes remain available.</p>
|
||||||
|
{#if editing.talent.id}<label class="protocol-talent-removal"><input type="checkbox" name="confirmTalentRemoval" value="REMOVE" required/> CONFIRM REMOVAL OF THE EXISTING TALENT AND MECHANIC PROGRAM</label>{/if}
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{#if form?.protocolError}<p class="error">{form.protocolError}</p>{/if}
|
{#if form?.protocolError}<p class="error">{form.protocolError}</p>{/if}
|
||||||
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE PROTOCOL</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!configuredProfile() || data.stats.length === 0}>SAVE PROTOCOL →</button></div></div>
|
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE PROTOCOL</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!configuredProfile() || data.stats.length === 0}>SAVE PROTOCOL →</button></div></div>
|
||||||
@@ -206,7 +224,7 @@
|
|||||||
<div class="dashboard-table protocol-dashboard-table">
|
<div class="dashboard-table protocol-dashboard-table">
|
||||||
<div class="dashboard-row protocol-row table-heading"><SortableColumnHeader label="OS PROTOCOL" column="name" sort={data.sort}/><SortableColumnHeader label="QUALITY" column="quality" sort={data.sort}/><SortableColumnHeader label="TYPE" column="type" sort={data.sort}/><SortableColumnHeader label="UNIQUE PRIMARY" column="primary" sort={data.sort}/><SortableColumnHeader label="UNIQUE TALENT" column="talent" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
<div class="dashboard-row protocol-row table-heading"><SortableColumnHeader label="OS PROTOCOL" column="name" sort={data.sort}/><SortableColumnHeader label="QUALITY" column="quality" sort={data.sort}/><SortableColumnHeader label="TYPE" column="type" sort={data.sort}/><SortableColumnHeader label="UNIQUE PRIMARY" column="primary" sort={data.sort}/><SortableColumnHeader label="UNIQUE TALENT" column="talent" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
||||||
{#each data.protocols as protocol}
|
{#each data.protocols as protocol}
|
||||||
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1} class:pending-record={protocol.pending} class:registry-row-editing={editing?.id === protocol.id}><div class="registry-icon-identity"><ItemIcon src={protocol.iconUrl} alt=""/><div><strong>{protocol.name}</strong><small>{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}</small></div></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{protocol.talent?.name ?? 'NOT CONFIGURED'}</strong><small><span class="mini-diamonds">◆◆◆◆◆</span> {protocol.talent?.conditionalRule?.clauses?.length || protocol.pending ? 'MODELED · ' : 'CONFIG REQUIRED · '}5 UPGRADES</small></div><b class:pending={protocol.pending} class="state-dot">{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if protocol.pending}<span></span>{:else}<button aria-expanded={editing?.id === protocol.id} aria-controls={`protocol-editor-${protocol.id}`} onclick={() => edit(protocol)}>EDIT</button>{/if}</div>
|
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1} class:pending-record={protocol.pending} class:registry-row-editing={editing?.id === protocol.id}><div class="registry-icon-identity"><ItemIcon src={protocol.iconUrl} alt=""/><div><strong>{protocol.name}</strong><small>{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}</small></div></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{protocol.talent?.name ?? 'NOT ASSIGNED'}</strong><small>{#if protocol.talent}<span class="mini-diamonds">◆◆◆◆◆</span> {protocol.talent.conditionalRule?.clauses?.length || protocol.pending ? 'MODELED · ' : 'CAPTURE ONLY · '}5 UPGRADES{:else}OPTIONAL{/if}</small></div><b class:pending={protocol.pending} class="state-dot">{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if protocol.pending}<span></span>{:else}<button aria-expanded={editing?.id === protocol.id} aria-controls={`protocol-editor-${protocol.id}`} onclick={() => edit(protocol)}>EDIT</button>{/if}</div>
|
||||||
{#if editing?.id === protocol.id}<RegistryInlineEditor id={`protocol-editor-${protocol.id}`} label={`Edit ${protocol.name}`}>{@render protocolEditor()}</RegistryInlineEditor>{/if}
|
{#if editing?.id === protocol.id}<RegistryInlineEditor id={`protocol-editor-${protocol.id}`} label={`Edit ${protocol.name}`}>{@render protocolEditor()}</RegistryInlineEditor>{/if}
|
||||||
{:else}<div class="empty-dashboard"><strong>NO OS PROTOCOLS REGISTERED</strong><span>Configure shared secondary profiles, then add the first protocol.</span></div>{/each}
|
{:else}<div class="empty-dashboard"><strong>NO OS PROTOCOLS REGISTERED</strong><span>Configure shared secondary profiles, then add the first protocol.</span></div>{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => {
|
||||||
|
const transaction = { prepare: vi.fn() };
|
||||||
|
const database = {
|
||||||
|
prepare: vi.fn(),
|
||||||
|
transactionAsync: vi.fn(),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
transaction,
|
||||||
|
database,
|
||||||
|
profileExists: false,
|
||||||
|
effects: [] as Array<Record<string, unknown>>,
|
||||||
|
getAttributeIds: vi.fn(),
|
||||||
|
recordIsacAudit: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('$lib/server/auth/session', () => ({
|
||||||
|
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/auth/authorization', () => ({
|
||||||
|
requireDataView: vi.fn(async (user) => user),
|
||||||
|
requirePermission: vi.fn(async (user) => user),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/database', () => ({
|
||||||
|
getDatabase: vi.fn(async () => mocks.database),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/audit', () => ({
|
||||||
|
auditRecord: vi.fn(),
|
||||||
|
recordIsacAudit: mocks.recordIsacAudit,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/data-moderation', () => ({
|
||||||
|
getPendingEntities: vi.fn(async () => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/os-protocol-profiles', () => ({
|
||||||
|
getOsProtocolProfileAttributeIds: mocks.getAttributeIds,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/game-data-icons', () => ({
|
||||||
|
applyIconMutation: vi.fn(),
|
||||||
|
gameDataIconMetadata: vi.fn(),
|
||||||
|
gameDataIconUrl: vi.fn(() => ''),
|
||||||
|
GameDataIconValidationError: class extends Error {},
|
||||||
|
readIconMutation: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/managed-images', () => ({
|
||||||
|
applyGameDataRender: vi.fn(),
|
||||||
|
ManagedImageValidationError: class extends Error {},
|
||||||
|
managedImageUrl: vi.fn(() => ''),
|
||||||
|
processManagedImage: vi.fn(),
|
||||||
|
readGameDataRenderMutation: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/conditional-effects', () => ({
|
||||||
|
conditionalRuleSnapshot: vi.fn(),
|
||||||
|
loadConditionalRuleDraft: vi.fn(),
|
||||||
|
parseConditionalRulePayload: vi.fn(),
|
||||||
|
saveConditionalRuleInTransaction: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { actions } from './+page.server';
|
||||||
|
|
||||||
|
const request = (includeValues = true) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set('quality', 'Standard');
|
||||||
|
form.set('attributeType', 'Firepower');
|
||||||
|
if (includeValues) {
|
||||||
|
form.set('valueCore', '10');
|
||||||
|
form.set('valueDamage', '20');
|
||||||
|
form.set('valueHealth', '30');
|
||||||
|
}
|
||||||
|
return new Request('http://localhost/isac/protocols?/saveProfile', {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('OS Protocol shared profile action', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.profileExists = false;
|
||||||
|
mocks.effects = [];
|
||||||
|
mocks.getAttributeIds.mockResolvedValue({
|
||||||
|
Core: 'firepower-flat',
|
||||||
|
Damage: 'weapon-damage-flat',
|
||||||
|
Health: 'health-flat',
|
||||||
|
});
|
||||||
|
mocks.transaction.prepare.mockImplementation(async (sql: string) => ({
|
||||||
|
get: vi.fn(async () => {
|
||||||
|
if (sql.includes('WHERE quality=?'))
|
||||||
|
return mocks.profileExists ? { id: 'profile-1' } : null;
|
||||||
|
if (sql.includes('WHERE id=?'))
|
||||||
|
return mocks.profileExists
|
||||||
|
? {
|
||||||
|
id: 'profile-1',
|
||||||
|
quality: 'Standard',
|
||||||
|
primary_attribute_type: 'Firepower',
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
all: vi.fn(async () =>
|
||||||
|
sql.includes('os_protocol_secondary_effects') ? mocks.effects : [],
|
||||||
|
),
|
||||||
|
run: vi.fn(async (...args: unknown[]) => {
|
||||||
|
if (sql.includes('INSERT INTO os_protocol_secondary_profiles'))
|
||||||
|
mocks.profileExists = true;
|
||||||
|
if (sql.includes('DELETE FROM os_protocol_secondary_effects'))
|
||||||
|
mocks.effects = [];
|
||||||
|
if (sql.includes('INSERT INTO os_protocol_secondary_effects'))
|
||||||
|
mocks.effects.push({
|
||||||
|
profile_id: 'profile-1',
|
||||||
|
effect_role: args[2],
|
||||||
|
stat_attribute_id: args[3],
|
||||||
|
value: args[4],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
mocks.database.transactionAsync.mockImplementation(
|
||||||
|
(callback: (transaction: typeof mocks.transaction) => Promise<void>) => {
|
||||||
|
const run = vi.fn(() => callback(mocks.transaction));
|
||||||
|
return Object.assign(run, { immediate: run });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates and audits all three profile roles in one owned transaction', async () => {
|
||||||
|
const result = await actions.saveProfile?.({
|
||||||
|
cookies: {} as never,
|
||||||
|
request: request(),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
profileSaved: true,
|
||||||
|
resultToken: expect.any(String),
|
||||||
|
});
|
||||||
|
expect(mocks.database.transactionAsync).toHaveBeenCalledOnce();
|
||||||
|
expect(mocks.effects).toHaveLength(3);
|
||||||
|
expect(mocks.recordIsacAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
actorId: 'operator-1',
|
||||||
|
category: 'game_data',
|
||||||
|
action: 'os_protocol_profile.created',
|
||||||
|
before: null,
|
||||||
|
after: expect.objectContaining({
|
||||||
|
effects: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ effect_role: 'Core', value: 10 }),
|
||||||
|
expect.objectContaining({ effect_role: 'Damage', value: 20 }),
|
||||||
|
expect.objectContaining({ effect_role: 'Health', value: 30 }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
database: mocks.transaction,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not start persistence when a required Attribute is missing', async () => {
|
||||||
|
mocks.getAttributeIds.mockResolvedValue({
|
||||||
|
Core: 'firepower-flat',
|
||||||
|
Damage: null,
|
||||||
|
Health: 'health-flat',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await actions.saveProfile?.({
|
||||||
|
cookies: {} as never,
|
||||||
|
request: request(),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 400 });
|
||||||
|
expect(mocks.database.transactionAsync).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing numeric values instead of coercing them to zero', async () => {
|
||||||
|
const result = await actions.saveProfile?.({
|
||||||
|
cookies: {} as never,
|
||||||
|
request: request(false),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 400 });
|
||||||
|
expect(mocks.database.transactionAsync).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { connect, type Database } from '@tursodatabase/database';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
getDatabase: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/auth/session', () => ({
|
||||||
|
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/auth/authorization', () => ({
|
||||||
|
requireDataView: vi.fn(async (user) => user),
|
||||||
|
requirePermission: vi.fn(async (user) => user),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/database', () => ({
|
||||||
|
getDatabase: mocks.getDatabase,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/data-moderation', () => ({
|
||||||
|
getPendingEntities: vi.fn(async () => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { actions } from './+page.server';
|
||||||
|
|
||||||
|
let database: Database;
|
||||||
|
|
||||||
|
const profileRequest = () => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set('quality', 'Standard');
|
||||||
|
form.set('attributeType', 'Firepower');
|
||||||
|
form.set('valueCore', '10');
|
||||||
|
form.set('valueDamage', '20');
|
||||||
|
form.set('valueHealth', '30');
|
||||||
|
return new Request('http://localhost/isac/protocols?/saveProfile', {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const protocolRequest = () => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set('id', '');
|
||||||
|
form.set('name', 'Integration Protocol');
|
||||||
|
form.set('quality', 'Standard');
|
||||||
|
form.set('attributeType', 'Firepower');
|
||||||
|
form.set('primaryStatId', 'weapon-damage-flat');
|
||||||
|
form.set('primaryBaseValue', '5');
|
||||||
|
form.set('hasTalent', '0');
|
||||||
|
return new Request('http://localhost/isac/protocols?/save', {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('OS Protocol save actions with embedded Turso', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
database = await connect(':memory:');
|
||||||
|
mocks.getDatabase.mockResolvedValue(database);
|
||||||
|
await database.exec(`
|
||||||
|
CREATE TABLE stat_attributes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
unit TEXT NOT NULL,
|
||||||
|
active INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE os_protocols (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
quality TEXT NOT NULL,
|
||||||
|
primary_attribute_type TEXT NOT NULL,
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX os_protocols_name_nocase_idx ON os_protocols(name COLLATE NOCASE);
|
||||||
|
CREATE TABLE os_protocol_primary_effects (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
protocol_id TEXT NOT NULL UNIQUE,
|
||||||
|
stat_attribute_id TEXT NOT NULL,
|
||||||
|
base_value REAL NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE os_protocol_secondary_profiles (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
quality TEXT NOT NULL,
|
||||||
|
primary_attribute_type TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(quality, primary_attribute_type)
|
||||||
|
);
|
||||||
|
CREATE TABLE os_protocol_secondary_effects (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
profile_id TEXT NOT NULL,
|
||||||
|
effect_role TEXT NOT NULL,
|
||||||
|
stat_attribute_id TEXT NOT NULL,
|
||||||
|
value REAL NOT NULL,
|
||||||
|
UNIQUE(profile_id, effect_role),
|
||||||
|
UNIQUE(profile_id, stat_attribute_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE os_protocol_talents (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
protocol_id TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description_template TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE os_protocol_talent_effects (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
talent_id TEXT NOT NULL,
|
||||||
|
stat_attribute_id TEXT NOT NULL,
|
||||||
|
base_value REAL NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE game_data_icons (
|
||||||
|
entity_type TEXT NOT NULL,
|
||||||
|
entity_id TEXT NOT NULL,
|
||||||
|
storage_key TEXT,
|
||||||
|
byte_size INTEGER,
|
||||||
|
updated_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE managed_images (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
category TEXT,
|
||||||
|
tags_json TEXT,
|
||||||
|
source_entity_type TEXT,
|
||||||
|
source_entity_id TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE audit_log (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
actor_id TEXT NOT NULL,
|
||||||
|
entity_type TEXT NOT NULL,
|
||||||
|
entity_id TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
before_json TEXT,
|
||||||
|
after_json TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
for (const [id, name, unit] of [
|
||||||
|
['firepower-flat', 'Firepower', ''],
|
||||||
|
['firepower-percent', 'Firepower', '%'],
|
||||||
|
['weapon-damage-flat', 'Weapon Damage', ''],
|
||||||
|
['weapon-damage-percent', 'Weapon Damage', '%'],
|
||||||
|
['health-flat', 'Health', ''],
|
||||||
|
]) {
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
'INSERT INTO stat_attributes(id,name,unit,active) VALUES(?,?,?,1)',
|
||||||
|
)
|
||||||
|
).run(id, name, unit);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await database.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves a complete shared profile and then a no-Talent Protocol', async () => {
|
||||||
|
const profileResult = await actions.saveProfile?.({
|
||||||
|
cookies: {} as never,
|
||||||
|
request: profileRequest(),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(profileResult).toMatchObject({ profileSaved: true });
|
||||||
|
const profile = await (
|
||||||
|
await database.prepare(
|
||||||
|
"SELECT id FROM os_protocol_secondary_profiles WHERE quality='Standard' AND primary_attribute_type='Firepower'",
|
||||||
|
)
|
||||||
|
).get();
|
||||||
|
expect(profile).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT effect_role,stat_attribute_id,value FROM os_protocol_secondary_effects WHERE profile_id=? ORDER BY effect_role',
|
||||||
|
)
|
||||||
|
).all(profile?.id),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
effect_role: 'Core',
|
||||||
|
stat_attribute_id: 'firepower-flat',
|
||||||
|
value: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
effect_role: 'Damage',
|
||||||
|
stat_attribute_id: 'weapon-damage-flat',
|
||||||
|
value: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
effect_role: 'Health',
|
||||||
|
stat_attribute_id: 'health-flat',
|
||||||
|
value: 30,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const protocolResult = await actions.save?.({
|
||||||
|
cookies: {} as never,
|
||||||
|
request: protocolRequest(),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(protocolResult).toMatchObject({
|
||||||
|
protocolSaved: true,
|
||||||
|
protocolMode: 'add',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT name,quality,primary_attribute_type FROM os_protocols',
|
||||||
|
)
|
||||||
|
).get(),
|
||||||
|
).toEqual({
|
||||||
|
name: 'Integration Protocol',
|
||||||
|
quality: 'Standard',
|
||||||
|
primary_attribute_type: 'Firepower',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT entity_type,action FROM audit_log ORDER BY created_at',
|
||||||
|
)
|
||||||
|
).all(),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
entity_type: 'game_data',
|
||||||
|
action: 'os_protocol_profile.created',
|
||||||
|
},
|
||||||
|
{ entity_type: 'game_data', action: 'os_protocol.created' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const capturedRule = {
|
||||||
|
version: 1 as const,
|
||||||
|
simulationReadiness: 'captured_only' as const,
|
||||||
|
scenarioInputs: [],
|
||||||
|
pools: [],
|
||||||
|
clauses: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => {
|
||||||
|
const events: string[] = [];
|
||||||
|
const database = {
|
||||||
|
exec: vi.fn(async (sql: string) => {
|
||||||
|
events.push(sql);
|
||||||
|
}),
|
||||||
|
prepare: vi.fn(),
|
||||||
|
transactionAsync: vi.fn(),
|
||||||
|
};
|
||||||
|
database.transactionAsync.mockImplementation(
|
||||||
|
(callback: (transaction: typeof database) => Promise<unknown>) => {
|
||||||
|
const run = vi.fn(async () => {
|
||||||
|
events.push('BEGIN');
|
||||||
|
try {
|
||||||
|
const result = await callback(database);
|
||||||
|
events.push('COMMIT');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
events.push('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Object.assign(run, { immediate: run });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
events,
|
||||||
|
talentExists: false,
|
||||||
|
profileConfigured: true,
|
||||||
|
auditRecord: vi.fn(),
|
||||||
|
conditionalRuleSnapshot: vi.fn(),
|
||||||
|
parseConditionalRulePayload: vi.fn(),
|
||||||
|
recordIsacAudit: vi.fn(async () => {
|
||||||
|
events.push('audit');
|
||||||
|
}),
|
||||||
|
saveConditionalRuleInTransaction: vi.fn(async () => {
|
||||||
|
events.push('save conditional rule');
|
||||||
|
}),
|
||||||
|
database,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('$lib/server/auth/session', () => ({
|
||||||
|
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/auth/authorization', () => ({
|
||||||
|
requireDataView: vi.fn(async (user) => user),
|
||||||
|
requirePermission: vi.fn(async (user) => user),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/database', () => ({
|
||||||
|
getDatabase: vi.fn(async () => mocks.database),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/audit', () => ({
|
||||||
|
auditRecord: mocks.auditRecord,
|
||||||
|
recordIsacAudit: mocks.recordIsacAudit,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/data-moderation', () => ({
|
||||||
|
getPendingEntities: vi.fn(async () => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/os-protocol-profiles', () => ({
|
||||||
|
getOsProtocolProfileAttributeIds: vi.fn(async () => ({
|
||||||
|
Core: 'firepower',
|
||||||
|
Damage: 'weapon-damage',
|
||||||
|
Health: 'health',
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/game-data-icons', () => ({
|
||||||
|
applyIconMutation: vi.fn(),
|
||||||
|
gameDataIconMetadata: vi.fn(async () => null),
|
||||||
|
gameDataIconUrl: vi.fn(() => ''),
|
||||||
|
GameDataIconValidationError: class extends Error {},
|
||||||
|
readIconMutation: vi.fn(async () => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/managed-images', () => ({
|
||||||
|
applyGameDataRender: vi.fn(),
|
||||||
|
ManagedImageValidationError: class extends Error {},
|
||||||
|
managedImageUrl: vi.fn(() => ''),
|
||||||
|
processManagedImage: vi.fn(),
|
||||||
|
readGameDataRenderMutation: vi.fn(async () => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/conditional-effects', () => ({
|
||||||
|
conditionalRuleSnapshot: mocks.conditionalRuleSnapshot,
|
||||||
|
loadConditionalRuleDraft: vi.fn(),
|
||||||
|
parseConditionalRulePayload: mocks.parseConditionalRulePayload,
|
||||||
|
saveConditionalRuleInTransaction: mocks.saveConditionalRuleInTransaction,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { actions } from './+page.server';
|
||||||
|
|
||||||
|
const protocolId = 'protocol-1';
|
||||||
|
const talentId = 'talent-1';
|
||||||
|
const protocolRecord = {
|
||||||
|
id: protocolId,
|
||||||
|
name: 'Adaptive Kernel',
|
||||||
|
quality: 'Standard',
|
||||||
|
primary_attribute_type: 'Firepower',
|
||||||
|
active: 1,
|
||||||
|
};
|
||||||
|
const primaryEffect = {
|
||||||
|
protocol_id: protocolId,
|
||||||
|
stat_attribute_id: 'weapon-damage',
|
||||||
|
base_value: 5,
|
||||||
|
};
|
||||||
|
const talentRecord = {
|
||||||
|
id: talentId,
|
||||||
|
protocol_id: protocolId,
|
||||||
|
name: 'Escalation Loop',
|
||||||
|
description_template: 'Captured Talent',
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveRequest = ({
|
||||||
|
id = '',
|
||||||
|
hasTalent = false,
|
||||||
|
confirmRemoval = false,
|
||||||
|
includePrimaryValue = true,
|
||||||
|
}: {
|
||||||
|
id?: string;
|
||||||
|
hasTalent?: boolean;
|
||||||
|
confirmRemoval?: boolean;
|
||||||
|
includePrimaryValue?: boolean;
|
||||||
|
} = {}) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set('id', id);
|
||||||
|
form.set('name', 'Adaptive Kernel');
|
||||||
|
form.set('quality', 'Standard');
|
||||||
|
form.set('attributeType', 'Firepower');
|
||||||
|
form.set('primaryStatId', 'weapon-damage');
|
||||||
|
if (includePrimaryValue) form.set('primaryBaseValue', '5');
|
||||||
|
form.set('hasTalent', hasTalent ? '1' : '0');
|
||||||
|
if (hasTalent) {
|
||||||
|
form.set('talentName', 'Escalation Loop');
|
||||||
|
form.set('descriptionTemplate', 'Captured Talent');
|
||||||
|
form.set('conditionalRule', JSON.stringify(capturedRule));
|
||||||
|
}
|
||||||
|
if (confirmRemoval) form.set('confirmTalentRemoval', 'REMOVE');
|
||||||
|
return new Request('http://localhost/isac/protocols?/save', {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const runSave = (options?: Parameters<typeof saveRequest>[0]) =>
|
||||||
|
actions.save?.({
|
||||||
|
cookies: {} as never,
|
||||||
|
request: saveRequest(options),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
describe('OS Protocol optional Talent action', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.events.length = 0;
|
||||||
|
mocks.talentExists = false;
|
||||||
|
mocks.profileConfigured = true;
|
||||||
|
mocks.parseConditionalRulePayload.mockReturnValue(capturedRule);
|
||||||
|
mocks.conditionalRuleSnapshot.mockResolvedValue({
|
||||||
|
id: 'rule-1',
|
||||||
|
source_id: talentId,
|
||||||
|
});
|
||||||
|
mocks.auditRecord.mockResolvedValue(protocolRecord);
|
||||||
|
mocks.database.prepare.mockImplementation(async (sql: string) => ({
|
||||||
|
get: vi.fn(async (...args: unknown[]) => {
|
||||||
|
if (sql.includes('FROM os_protocol_secondary_profiles'))
|
||||||
|
return mocks.profileConfigured ? { id: 'profile-1' } : null;
|
||||||
|
if (sql.includes('FROM os_protocols WHERE name=')) return null;
|
||||||
|
if (sql.includes('FROM os_protocol_talents WHERE protocol_id='))
|
||||||
|
return mocks.talentExists ? talentRecord : null;
|
||||||
|
if (sql.includes('FROM os_protocol_talents WHERE name=')) return null;
|
||||||
|
if (sql.includes('COUNT(*) AS count FROM stat_attributes'))
|
||||||
|
return { count: args.length };
|
||||||
|
if (sql.includes('FROM managed_images')) return null;
|
||||||
|
if (sql.includes('FROM os_protocol_primary_effects'))
|
||||||
|
return primaryEffect;
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
all: vi.fn(async () => []),
|
||||||
|
run: vi.fn(async () => {
|
||||||
|
if (sql.includes('INSERT INTO os_protocol_talents'))
|
||||||
|
mocks.talentExists = true;
|
||||||
|
if (sql.includes('DELETE FROM conditional_effect_rules'))
|
||||||
|
mocks.events.push('delete conditional rule');
|
||||||
|
if (sql.includes('DELETE FROM os_protocol_talents')) {
|
||||||
|
mocks.events.push('delete talent');
|
||||||
|
mocks.talentExists = false;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a protocol without a Talent', async () => {
|
||||||
|
const result = await runSave();
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
protocolSaved: true,
|
||||||
|
protocolMode: 'add',
|
||||||
|
resultToken: expect.any(String),
|
||||||
|
});
|
||||||
|
expect(mocks.parseConditionalRulePayload).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.saveConditionalRuleInTransaction).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
mocks.database.prepare.mock.calls.some(([sql]) =>
|
||||||
|
String(sql).includes('INSERT INTO os_protocol_talents'),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(mocks.recordIsacAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
actorId: 'operator-1',
|
||||||
|
category: 'game_data',
|
||||||
|
action: 'os_protocol.created',
|
||||||
|
before: null,
|
||||||
|
after: expect.objectContaining({ talent: null, conditional: null }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a protocol whose shared profile is incomplete', async () => {
|
||||||
|
mocks.profileConfigured = false;
|
||||||
|
|
||||||
|
const result = await runSave();
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 400,
|
||||||
|
data: {
|
||||||
|
protocolError:
|
||||||
|
'Configure the shared secondary profile for this quality and attribute type first.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mocks.database.transactionAsync).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a missing primary value instead of coercing it to zero', async () => {
|
||||||
|
const result = await runSave({ includePrimaryValue: false });
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 400 });
|
||||||
|
expect(mocks.database.transactionAsync).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an assigned CAPTURE ONLY Talent with no mechanic branches', async () => {
|
||||||
|
const result = await runSave({ hasTalent: true });
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
protocolSaved: true,
|
||||||
|
protocolMode: 'add',
|
||||||
|
});
|
||||||
|
expect(mocks.parseConditionalRulePayload).toHaveBeenCalledOnce();
|
||||||
|
expect(mocks.saveConditionalRuleInTransaction).toHaveBeenCalledWith(
|
||||||
|
mocks.database,
|
||||||
|
'os_protocol_talent',
|
||||||
|
expect.any(String),
|
||||||
|
capturedRule,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['ready', 'requires_assumptions'] as const)(
|
||||||
|
'rejects an empty %s mechanic program',
|
||||||
|
async (simulationReadiness) => {
|
||||||
|
mocks.parseConditionalRulePayload.mockReturnValue({
|
||||||
|
...capturedRule,
|
||||||
|
simulationReadiness,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runSave({ hasTalent: true });
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 400,
|
||||||
|
data: {
|
||||||
|
protocolError:
|
||||||
|
'Add a mechanic branch and outcome, or mark the Talent CAPTURE ONLY.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mocks.database.transactionAsync).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('requires confirmation before removing an existing Talent', async () => {
|
||||||
|
mocks.talentExists = true;
|
||||||
|
|
||||||
|
const result = await runSave({ id: protocolId });
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 400,
|
||||||
|
data: {
|
||||||
|
protocolError:
|
||||||
|
'Confirm removal of the existing Talent and mechanic program.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mocks.database.transactionAsync).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes an existing Talent and its mechanic in the audited transaction', async () => {
|
||||||
|
mocks.talentExists = true;
|
||||||
|
|
||||||
|
const result = await runSave({
|
||||||
|
id: protocolId,
|
||||||
|
confirmRemoval: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
protocolSaved: true,
|
||||||
|
protocolMode: 'edit',
|
||||||
|
});
|
||||||
|
expect(mocks.events).toEqual([
|
||||||
|
'BEGIN',
|
||||||
|
'delete conditional rule',
|
||||||
|
'delete talent',
|
||||||
|
'audit',
|
||||||
|
'COMMIT',
|
||||||
|
]);
|
||||||
|
expect(mocks.recordIsacAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
actorId: 'operator-1',
|
||||||
|
category: 'game_data',
|
||||||
|
action: 'os_protocol.updated',
|
||||||
|
entityId: protocolId,
|
||||||
|
before: expect.objectContaining({
|
||||||
|
talent: talentRecord,
|
||||||
|
conditional: expect.objectContaining({ source_id: talentId }),
|
||||||
|
}),
|
||||||
|
after: expect.objectContaining({ talent: null, conditional: null }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,7 +25,10 @@ import {
|
|||||||
getRegistrySearch,
|
getRegistrySearch,
|
||||||
getRegistrySort,
|
getRegistrySort,
|
||||||
} from '$lib/server/pagination';
|
} from '$lib/server/pagination';
|
||||||
import { refreshAbilityFocuses } from '$lib/server/specialization-readiness';
|
import {
|
||||||
|
refreshAbilityFocuses,
|
||||||
|
refreshSpecializationFocusReady,
|
||||||
|
} from '$lib/server/specialization-readiness';
|
||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
@@ -36,6 +39,18 @@ import {
|
|||||||
saveConditionalRuleInTransaction,
|
saveConditionalRuleInTransaction,
|
||||||
} from '$lib/server/conditional-effects';
|
} from '$lib/server/conditional-effects';
|
||||||
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
|
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
|
||||||
|
import {
|
||||||
|
normalizeSkillChipProfiles,
|
||||||
|
SKILL_CHIP_QUALITIES,
|
||||||
|
SKILL_CHIP_SECONDARY_QUALITIES,
|
||||||
|
} from '$lib/skill-chips/profiles';
|
||||||
|
import {
|
||||||
|
getSkillChipPrimaryAttributeIds,
|
||||||
|
getSkillChipPrimaryProfiles,
|
||||||
|
getSkillChipSecondaryPool,
|
||||||
|
replaceSkillChipPrimaryProfiles,
|
||||||
|
replaceSkillChipSecondaryPool,
|
||||||
|
} from '$lib/server/skill-chip-profiles';
|
||||||
|
|
||||||
const mechanicOutcomes = (
|
const mechanicOutcomes = (
|
||||||
rule: ReturnType<typeof parseConditionalRulePayload>,
|
rule: ReturnType<typeof parseConditionalRulePayload>,
|
||||||
@@ -68,6 +83,16 @@ const renderAuditMetadata = (render: Record<string, any> | null | undefined) =>
|
|||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
async function refreshAllFocusReadiness(
|
||||||
|
database: Awaited<ReturnType<typeof getDatabase>>,
|
||||||
|
) {
|
||||||
|
const focuses = await (
|
||||||
|
await database.prepare('SELECT id FROM specialization_focuses')
|
||||||
|
).all();
|
||||||
|
for (const focus of focuses)
|
||||||
|
await refreshSpecializationFocusReady(database, String(focus.id));
|
||||||
|
}
|
||||||
|
|
||||||
async function snapshot(id: string) {
|
async function snapshot(id: string) {
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const talent = await (
|
const talent = await (
|
||||||
@@ -91,16 +116,6 @@ async function snapshot(id: string) {
|
|||||||
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?',
|
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?',
|
||||||
)
|
)
|
||||||
).get(id)) ?? null,
|
).get(id)) ?? null,
|
||||||
primaryOptions: await (
|
|
||||||
await database.prepare(
|
|
||||||
'SELECT stat_attribute_id FROM skill_chip_primary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id',
|
|
||||||
)
|
|
||||||
).all(id),
|
|
||||||
secondaryOptions: await (
|
|
||||||
await database.prepare(
|
|
||||||
'SELECT stat_attribute_id FROM skill_chip_secondary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id',
|
|
||||||
)
|
|
||||||
).all(id),
|
|
||||||
talent: talent ?? null,
|
talent: talent ?? null,
|
||||||
talentEffects: talent
|
talentEffects: talent
|
||||||
? await (
|
? await (
|
||||||
@@ -150,8 +165,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
desc: "COALESCE((SELECT talent.name FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id),'') COLLATE NOCASE DESC,sets.name COLLATE NOCASE ASC",
|
desc: "COALESCE((SELECT talent.name FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id),'') COLLATE NOCASE DESC,sets.name COLLATE NOCASE ASC",
|
||||||
},
|
},
|
||||||
rollPools: {
|
rollPools: {
|
||||||
asc: '((SELECT COUNT(*) FROM skill_chip_primary_options primary_option WHERE primary_option.skill_chip_set_id=sets.id)+(SELECT COUNT(*) FROM skill_chip_secondary_options secondary_option WHERE secondary_option.skill_chip_set_id=sets.id)) ASC,sets.name COLLATE NOCASE ASC',
|
asc: 'sets.name COLLATE NOCASE ASC,sets.id ASC',
|
||||||
desc: '((SELECT COUNT(*) FROM skill_chip_primary_options primary_option WHERE primary_option.skill_chip_set_id=sets.id)+(SELECT COUNT(*) FROM skill_chip_secondary_options secondary_option WHERE secondary_option.skill_chip_set_id=sets.id)) DESC,sets.name COLLATE NOCASE ASC',
|
desc: 'sets.name COLLATE NOCASE DESC,sets.id DESC',
|
||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
asc: 'sets.active ASC,sets.name COLLATE NOCASE ASC',
|
asc: 'sets.active ASC,sets.name COLLATE NOCASE ASC',
|
||||||
@@ -173,7 +188,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
: filter === 'configured'
|
: filter === 'configured'
|
||||||
? [
|
? [
|
||||||
'EXISTS (SELECT 1 FROM skill_chip_two_piece_bonuses bonus WHERE bonus.skill_chip_set_id=sets.id)',
|
'EXISTS (SELECT 1 FROM skill_chip_two_piece_bonuses bonus WHERE bonus.skill_chip_set_id=sets.id)',
|
||||||
'EXISTS (SELECT 1 FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id)',
|
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(!requestedItemId && search
|
...(!requestedItemId && search
|
||||||
@@ -198,7 +212,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
||||||
SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived,
|
SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived,
|
||||||
SUM(CASE WHEN specialization_ability_id IS NOT NULL THEN 1 ELSE 0 END) AS assigned,
|
SUM(CASE WHEN specialization_ability_id IS NOT NULL THEN 1 ELSE 0 END) AS assigned,
|
||||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=skill_chip_sets.id) THEN 1 ELSE 0 END) AS configured
|
SUM(CASE WHEN EXISTS (SELECT 1 FROM skill_chip_two_piece_bonuses bonus WHERE bonus.skill_chip_set_id=skill_chip_sets.id) THEN 1 ELSE 0 END) AS configured
|
||||||
FROM skill_chip_sets`)
|
FROM skill_chip_sets`)
|
||||||
).get(),
|
).get(),
|
||||||
(
|
(
|
||||||
@@ -211,7 +225,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
module: 'attributes',
|
module: 'attributes',
|
||||||
action: 'save',
|
action: 'save',
|
||||||
kind: 'stat',
|
kind: 'stat',
|
||||||
fields: ['unit', 'chipMin', 'chipMax'],
|
fields: ['unit'],
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
getPendingEntities(user.id, [
|
getPendingEntities(user.id, [
|
||||||
@@ -224,8 +238,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
'twoPieceStatId',
|
'twoPieceStatId',
|
||||||
'twoPieceMin',
|
'twoPieceMin',
|
||||||
'twoPieceMax',
|
'twoPieceMax',
|
||||||
'primaryStatIds',
|
'hasTalent',
|
||||||
'secondaryStatIds',
|
|
||||||
'talentName',
|
'talentName',
|
||||||
'descriptionTemplate',
|
'descriptionTemplate',
|
||||||
'conditionalRule',
|
'conditionalRule',
|
||||||
@@ -252,8 +265,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
const [
|
const [
|
||||||
sets,
|
sets,
|
||||||
twoPiece,
|
twoPiece,
|
||||||
primaryOptions,
|
|
||||||
secondaryOptions,
|
|
||||||
talents,
|
talents,
|
||||||
talentEffects,
|
talentEffects,
|
||||||
stats,
|
stats,
|
||||||
@@ -262,6 +273,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
abilityChipCounts,
|
abilityChipCounts,
|
||||||
icons,
|
icons,
|
||||||
renders,
|
renders,
|
||||||
|
primaryProfiles,
|
||||||
|
secondaryPool,
|
||||||
|
primaryAttributeIds,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
(
|
(
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
@@ -271,10 +285,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
(
|
(
|
||||||
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
|
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
|
||||||
).all(),
|
).all(),
|
||||||
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
|
|
||||||
(
|
|
||||||
await database.prepare('SELECT * FROM skill_chip_secondary_options')
|
|
||||||
).all(),
|
|
||||||
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
|
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
|
||||||
(
|
(
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
@@ -283,7 +293,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
).all(),
|
).all(),
|
||||||
(
|
(
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
'SELECT id,name,unit,skill_chip_min,skill_chip_max FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
|
'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||||
)
|
)
|
||||||
).all(),
|
).all(),
|
||||||
(
|
(
|
||||||
@@ -310,13 +320,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='skill_chip_render'",
|
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='skill_chip_render'",
|
||||||
)
|
)
|
||||||
).all(),
|
).all(),
|
||||||
|
getSkillChipPrimaryProfiles(database),
|
||||||
|
getSkillChipSecondaryPool(database),
|
||||||
|
getSkillChipPrimaryAttributeIds(database),
|
||||||
]);
|
]);
|
||||||
const availableStats = [
|
const availableStats = [
|
||||||
...pendingStats.map((item) => ({
|
...pendingStats.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
unit: String(item.unit ?? ''),
|
unit: String(item.unit ?? ''),
|
||||||
skill_chip_min: item.chipMin === '' ? null : Number(item.chipMin),
|
|
||||||
skill_chip_max: item.chipMax === '' ? null : Number(item.chipMax),
|
|
||||||
})),
|
})),
|
||||||
...stats,
|
...stats,
|
||||||
];
|
];
|
||||||
@@ -347,9 +358,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
const currentPendingSets = pendingSets.filter(
|
const currentPendingSets = pendingSets.filter(
|
||||||
(set) =>
|
(set) =>
|
||||||
Boolean(set.abilityId) &&
|
Boolean(set.abilityId) &&
|
||||||
Array.isArray(set.primaryStatIds) &&
|
(String(set.hasTalent) !== '1' || Boolean(set.conditionalRule)),
|
||||||
Array.isArray(set.secondaryStatIds) &&
|
|
||||||
Boolean(set.conditionalRule),
|
|
||||||
);
|
);
|
||||||
const pendingSetRows = requestedItemId
|
const pendingSetRows = requestedItemId
|
||||||
? currentPendingSets.filter((set) => set.id === requestedItemId)
|
? currentPendingSets.filter((set) => set.id === requestedItemId)
|
||||||
@@ -374,19 +383,22 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
min_value: Number(set.twoPieceMin),
|
min_value: Number(set.twoPieceMin),
|
||||||
max_value: Number(set.twoPieceMax),
|
max_value: Number(set.twoPieceMax),
|
||||||
},
|
},
|
||||||
primaryStatIds: set.primaryStatIds,
|
talent:
|
||||||
secondaryStatIds: set.secondaryStatIds,
|
String(set.hasTalent) === '1'
|
||||||
talent: {
|
? {
|
||||||
name: set.talentName,
|
name: set.talentName,
|
||||||
description_template: set.descriptionTemplate,
|
description_template: set.descriptionTemplate,
|
||||||
conditionalRule: (() => {
|
conditionalRule: (() => {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(String(set.conditionalRule ?? 'null'));
|
return JSON.parse(
|
||||||
} catch {
|
String(set.conditionalRule ?? 'null'),
|
||||||
return null;
|
);
|
||||||
}
|
} catch {
|
||||||
})(),
|
return null;
|
||||||
},
|
}
|
||||||
|
})(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
}));
|
}));
|
||||||
const configuredSets = await Promise.all(
|
const configuredSets = await Promise.all(
|
||||||
sets.map(async (set) => {
|
sets.map(async (set) => {
|
||||||
@@ -407,12 +419,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
})(),
|
})(),
|
||||||
twoPiece:
|
twoPiece:
|
||||||
twoPiece.find((item) => item.skill_chip_set_id === set.id) ?? null,
|
twoPiece.find((item) => item.skill_chip_set_id === set.id) ?? null,
|
||||||
primaryStatIds: primaryOptions
|
|
||||||
.filter((item) => item.skill_chip_set_id === set.id)
|
|
||||||
.map((item) => String(item.stat_attribute_id)),
|
|
||||||
secondaryStatIds: secondaryOptions
|
|
||||||
.filter((item) => item.skill_chip_set_id === set.id)
|
|
||||||
.map((item) => String(item.stat_attribute_id)),
|
|
||||||
talent: talent
|
talent: talent
|
||||||
? {
|
? {
|
||||||
...talent,
|
...talent,
|
||||||
@@ -433,9 +439,15 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
return {
|
return {
|
||||||
sets: [...pendingSetRows, ...configuredSets],
|
sets: [...pendingSetRows, ...configuredSets],
|
||||||
stats: availableStats,
|
stats: availableStats,
|
||||||
rollStats: availableStats.filter(
|
primaryProfiles: primaryAttributeIds.map(
|
||||||
(stat) => stat.skill_chip_min !== null && stat.skill_chip_max !== null,
|
(statId) =>
|
||||||
|
primaryProfiles.find((profile) => profile.statId === statId) ?? {
|
||||||
|
statId,
|
||||||
|
values: {},
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
primaryProfileBase: primaryProfiles,
|
||||||
|
secondaryPool,
|
||||||
abilities: loadedAbilities,
|
abilities: loadedAbilities,
|
||||||
summary: {
|
summary: {
|
||||||
active: Number(summary?.active ?? 0),
|
active: Number(summary?.active ?? 0),
|
||||||
@@ -490,12 +502,7 @@ export const actions: Actions = {
|
|||||||
const twoPieceStatId = String(form.get('twoPieceStatId') ?? '');
|
const twoPieceStatId = String(form.get('twoPieceStatId') ?? '');
|
||||||
const twoPieceMin = Number(form.get('twoPieceMin'));
|
const twoPieceMin = Number(form.get('twoPieceMin'));
|
||||||
const twoPieceMax = Number(form.get('twoPieceMax'));
|
const twoPieceMax = Number(form.get('twoPieceMax'));
|
||||||
const primaryStatIds = [
|
const hasTalent = form.get('hasTalent') === '1';
|
||||||
...new Set(form.getAll('primaryStatIds').map(String).filter(Boolean)),
|
|
||||||
];
|
|
||||||
const secondaryStatIds = [
|
|
||||||
...new Set(form.getAll('secondaryStatIds').map(String).filter(Boolean)),
|
|
||||||
];
|
|
||||||
const talentName = String(form.get('talentName') ?? '')
|
const talentName = String(form.get('talentName') ?? '')
|
||||||
.normalize('NFKC')
|
.normalize('NFKC')
|
||||||
.trim();
|
.trim();
|
||||||
@@ -503,25 +510,30 @@ export const actions: Actions = {
|
|||||||
.normalize('NFKC')
|
.normalize('NFKC')
|
||||||
.trim();
|
.trim();
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
let conditionalRule;
|
let conditionalRule: ReturnType<typeof parseConditionalRulePayload> | null =
|
||||||
|
null;
|
||||||
try {
|
try {
|
||||||
const conditionalRuleRaw = form.get('conditionalRule');
|
if (hasTalent) {
|
||||||
const activeStats = await (
|
const conditionalRuleRaw = form.get('conditionalRule');
|
||||||
await database.prepare('SELECT id FROM stat_attributes WHERE active=1')
|
const activeStats = await (
|
||||||
).all();
|
await database.prepare(
|
||||||
conditionalRule = parseConditionalRulePayload(
|
'SELECT id FROM stat_attributes WHERE active=1',
|
||||||
conditionalRuleRaw,
|
)
|
||||||
[
|
).all();
|
||||||
{ key: 'minimum', variantKey: 'minimum' },
|
conditionalRule = parseConditionalRulePayload(
|
||||||
{ key: 'maximum', variantKey: 'maximum' },
|
conditionalRuleRaw,
|
||||||
],
|
[
|
||||||
{
|
{ key: 'minimum', variantKey: 'minimum' },
|
||||||
allowedAttributeIds: new Set(
|
{ key: 'maximum', variantKey: 'maximum' },
|
||||||
activeStats.map((row) => String(row.id)),
|
],
|
||||||
),
|
{
|
||||||
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
|
allowedAttributeIds: new Set(
|
||||||
},
|
activeStats.map((row) => String(row.id)),
|
||||||
);
|
),
|
||||||
|
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
skillChipError:
|
skillChipError:
|
||||||
@@ -547,38 +559,30 @@ export const actions: Actions = {
|
|||||||
return fail(400, {
|
return fail(400, {
|
||||||
skillChipError: 'Configure a valid 2-piece stat range.',
|
skillChipError: 'Configure a valid 2-piece stat range.',
|
||||||
});
|
});
|
||||||
if (!primaryStatIds.length)
|
const outcomes = conditionalRule ? mechanicOutcomes(conditionalRule) : [];
|
||||||
return fail(400, {
|
const numberedOutcomes = conditionalRule
|
||||||
skillChipError: 'Configure at least one possible primary stat.',
|
? descriptionOutcomes(conditionalRule)
|
||||||
});
|
: [];
|
||||||
if (secondaryStatIds.length < 2)
|
|
||||||
return fail(400, {
|
|
||||||
skillChipError: 'Configure at least two possible secondary stats.',
|
|
||||||
});
|
|
||||||
if (primaryStatIds.some((statId) => secondaryStatIds.includes(statId)))
|
|
||||||
return fail(400, {
|
|
||||||
skillChipError: 'Primary and secondary stat pools must be distinct.',
|
|
||||||
});
|
|
||||||
const outcomes = mechanicOutcomes(conditionalRule);
|
|
||||||
const numberedOutcomes = descriptionOutcomes(conditionalRule);
|
|
||||||
const durationSeconds =
|
const durationSeconds =
|
||||||
conditionalRule.pools.find((pool) => pool.expiresAfterSeconds)
|
conditionalRule?.pools.find((pool) => pool.expiresAfterSeconds)
|
||||||
?.expiresAfterSeconds ?? null;
|
?.expiresAfterSeconds ?? null;
|
||||||
const maxStacks = conditionalRule.pools.length
|
const maxStacks = conditionalRule?.pools.length
|
||||||
? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1))
|
? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1))
|
||||||
: null;
|
: null;
|
||||||
if (
|
if (
|
||||||
talentName.length < 2 ||
|
hasTalent &&
|
||||||
talentName.length > 80 ||
|
(talentName.length < 2 ||
|
||||||
!descriptionTemplate ||
|
talentName.length > 80 ||
|
||||||
!conditionalRule.clauses.length ||
|
!descriptionTemplate ||
|
||||||
!outcomes.length
|
!conditionalRule?.clauses.length ||
|
||||||
|
!outcomes.length)
|
||||||
)
|
)
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
skillChipError:
|
skillChipError:
|
||||||
'The 3-piece talent requires a name, description, mechanic branch, and outcome.',
|
'The 3-piece talent requires a name, description, mechanic branch, and outcome.',
|
||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
|
hasTalent &&
|
||||||
outcomes.some(
|
outcomes.some(
|
||||||
(outcome) =>
|
(outcome) =>
|
||||||
outcome.variants.maximum.value < outcome.variants.minimum.value,
|
outcome.variants.maximum.value < outcome.variants.minimum.value,
|
||||||
@@ -592,6 +596,7 @@ export const actions: Actions = {
|
|||||||
(match) => Number(match[1]),
|
(match) => Number(match[1]),
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
|
hasTalent &&
|
||||||
placeholders.some(
|
placeholders.some(
|
||||||
(index) =>
|
(index) =>
|
||||||
!numberedOutcomes.some(
|
!numberedOutcomes.some(
|
||||||
@@ -603,12 +608,16 @@ export const actions: Actions = {
|
|||||||
skillChipError:
|
skillChipError:
|
||||||
'Description placeholders must reference configured numbered outcomes.',
|
'Description placeholders must reference configured numbered outcomes.',
|
||||||
});
|
});
|
||||||
if (descriptionTemplate.includes('{duration}') && durationSeconds === null)
|
if (
|
||||||
|
hasTalent &&
|
||||||
|
descriptionTemplate.includes('{duration}') &&
|
||||||
|
durationSeconds === null
|
||||||
|
)
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
skillChipError:
|
skillChipError:
|
||||||
'The description uses {duration}, but no expiring state pool is defined.',
|
'The description uses {duration}, but no expiring state pool is defined.',
|
||||||
});
|
});
|
||||||
if (descriptionTemplate.includes('{max_stacks}') && !maxStacks)
|
if (hasTalent && descriptionTemplate.includes('{max_stacks}') && !maxStacks)
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
skillChipError:
|
skillChipError:
|
||||||
'The description uses {max_stacks}, but no state pool maximum is defined.',
|
'The description uses {max_stacks}, but no state pool maximum is defined.',
|
||||||
@@ -656,11 +665,21 @@ export const actions: Actions = {
|
|||||||
).get(id)
|
).get(id)
|
||||||
: null;
|
: null;
|
||||||
if (
|
if (
|
||||||
await (
|
existingTalent &&
|
||||||
|
!hasTalent &&
|
||||||
|
form.get('confirmTalentRemoval') !== 'REMOVE'
|
||||||
|
)
|
||||||
|
return fail(400, {
|
||||||
|
skillChipError:
|
||||||
|
'Confirm removal of the existing 3-piece Talent and mechanic program.',
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
hasTalent &&
|
||||||
|
(await (
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
'SELECT id FROM skill_chip_talents WHERE name=? COLLATE NOCASE AND id<>?',
|
'SELECT id FROM skill_chip_talents WHERE name=? COLLATE NOCASE AND id<>?',
|
||||||
)
|
)
|
||||||
).get(talentName, existingTalent?.id ?? 'new')
|
).get(talentName, existingTalent?.id ?? 'new'))
|
||||||
)
|
)
|
||||||
return fail(409, {
|
return fail(409, {
|
||||||
skillChipError: 'That Skill Chip talent name is already in use.',
|
skillChipError: 'That Skill Chip talent name is already in use.',
|
||||||
@@ -668,14 +687,7 @@ export const actions: Actions = {
|
|||||||
const outcomeStatIds = outcomes
|
const outcomeStatIds = outcomes
|
||||||
.map((effect) => effect.attributeId)
|
.map((effect) => effect.attributeId)
|
||||||
.filter((value): value is string => Boolean(value));
|
.filter((value): value is string => Boolean(value));
|
||||||
const allStatIds = [
|
const allStatIds = [...new Set([twoPieceStatId, ...outcomeStatIds])];
|
||||||
...new Set([
|
|
||||||
twoPieceStatId,
|
|
||||||
...primaryStatIds,
|
|
||||||
...secondaryStatIds,
|
|
||||||
...outcomeStatIds,
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
const validStats = await (
|
const validStats = await (
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${allStatIds.map(() => '?').join(',')})`,
|
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${allStatIds.map(() => '?').join(',')})`,
|
||||||
@@ -685,17 +697,6 @@ export const actions: Actions = {
|
|||||||
return fail(400, {
|
return fail(400, {
|
||||||
skillChipError: 'One or more selected stats are unavailable.',
|
skillChipError: 'One or more selected stats are unavailable.',
|
||||||
});
|
});
|
||||||
const rollStatIds = [...primaryStatIds, ...secondaryStatIds];
|
|
||||||
const validRollStats = await (
|
|
||||||
await database.prepare(
|
|
||||||
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND skill_chip_min IS NOT NULL AND skill_chip_max IS NOT NULL AND id IN (${rollStatIds.map(() => '?').join(',')})`,
|
|
||||||
)
|
|
||||||
).get(...rollStatIds);
|
|
||||||
if (Number(validRollStats?.count) !== rollStatIds.length)
|
|
||||||
return fail(400, {
|
|
||||||
skillChipError:
|
|
||||||
'Every primary and secondary option requires a configured Skill Chip range.',
|
|
||||||
});
|
|
||||||
const setId =
|
const setId =
|
||||||
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||||||
const talentId = String(existingTalent?.id ?? randomUUID());
|
const talentId = String(existingTalent?.id ?? randomUUID());
|
||||||
@@ -724,61 +725,50 @@ export const actions: Actions = {
|
|||||||
await database.prepare(`INSERT INTO skill_chip_two_piece_bonuses(id,skill_chip_set_id,stat_attribute_id,min_value,max_value) VALUES(?,?,?,?,?)
|
await database.prepare(`INSERT INTO skill_chip_two_piece_bonuses(id,skill_chip_set_id,stat_attribute_id,min_value,max_value) VALUES(?,?,?,?,?)
|
||||||
ON CONFLICT(skill_chip_set_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,min_value=excluded.min_value,max_value=excluded.max_value`)
|
ON CONFLICT(skill_chip_set_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,min_value=excluded.min_value,max_value=excluded.max_value`)
|
||||||
).run(randomUUID(), setId, twoPieceStatId, twoPieceMin, twoPieceMax);
|
).run(randomUUID(), setId, twoPieceStatId, twoPieceMin, twoPieceMax);
|
||||||
await (
|
if (hasTalent && conditionalRule) {
|
||||||
await database.prepare(
|
|
||||||
'DELETE FROM skill_chip_primary_options WHERE skill_chip_set_id=?',
|
|
||||||
)
|
|
||||||
).run(setId);
|
|
||||||
for (const statId of primaryStatIds)
|
|
||||||
await (
|
await (
|
||||||
await database.prepare(
|
await database.prepare(`INSERT INTO skill_chip_talents(id,skill_chip_set_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
|
||||||
'INSERT INTO skill_chip_primary_options(skill_chip_set_id,stat_attribute_id) VALUES(?,?)',
|
|
||||||
)
|
|
||||||
).run(setId, statId);
|
|
||||||
await (
|
|
||||||
await database.prepare(
|
|
||||||
'DELETE FROM skill_chip_secondary_options WHERE skill_chip_set_id=?',
|
|
||||||
)
|
|
||||||
).run(setId);
|
|
||||||
for (const statId of secondaryStatIds)
|
|
||||||
await (
|
|
||||||
await database.prepare(
|
|
||||||
'INSERT INTO skill_chip_secondary_options(skill_chip_set_id,stat_attribute_id) VALUES(?,?)',
|
|
||||||
)
|
|
||||||
).run(setId, statId);
|
|
||||||
await (
|
|
||||||
await database.prepare(`INSERT INTO skill_chip_talents(id,skill_chip_set_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
|
|
||||||
ON CONFLICT(skill_chip_set_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)
|
ON CONFLICT(skill_chip_set_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)
|
||||||
).run(talentId, setId, talentName, descriptionTemplate, now, now);
|
).run(talentId, setId, talentName, descriptionTemplate, now, now);
|
||||||
await (
|
|
||||||
await database.prepare(
|
|
||||||
'DELETE FROM skill_chip_talent_effects WHERE talent_id=?',
|
|
||||||
)
|
|
||||||
).run(talentId);
|
|
||||||
let compatibilitySortOrder = 0;
|
|
||||||
for (const outcome of numberedOutcomes) {
|
|
||||||
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId)
|
|
||||||
continue;
|
|
||||||
compatibilitySortOrder += 1;
|
|
||||||
await (
|
await (
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
'INSERT INTO skill_chip_talent_effects(id,talent_id,stat_attribute_id,min_value,max_value,sort_order) VALUES(?,?,?,?,?,?)',
|
'DELETE FROM skill_chip_talent_effects WHERE talent_id=?',
|
||||||
)
|
)
|
||||||
).run(
|
).run(talentId);
|
||||||
randomUUID(),
|
let compatibilitySortOrder = 0;
|
||||||
|
for (const outcome of numberedOutcomes) {
|
||||||
|
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId)
|
||||||
|
continue;
|
||||||
|
compatibilitySortOrder += 1;
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
'INSERT INTO skill_chip_talent_effects(id,talent_id,stat_attribute_id,min_value,max_value,sort_order) VALUES(?,?,?,?,?,?)',
|
||||||
|
)
|
||||||
|
).run(
|
||||||
|
randomUUID(),
|
||||||
|
talentId,
|
||||||
|
outcome.attributeId,
|
||||||
|
outcome.variants.minimum.value,
|
||||||
|
outcome.variants.maximum.value,
|
||||||
|
compatibilitySortOrder,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await saveConditionalRuleInTransaction(
|
||||||
|
database,
|
||||||
|
'skill_chip_talent',
|
||||||
talentId,
|
talentId,
|
||||||
outcome.attributeId,
|
conditionalRule,
|
||||||
outcome.variants.minimum.value,
|
|
||||||
outcome.variants.maximum.value,
|
|
||||||
compatibilitySortOrder,
|
|
||||||
);
|
);
|
||||||
|
} else if (existingTalent) {
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
"DELETE FROM conditional_effect_rules WHERE source_type='skill_chip_talent' AND source_id=?",
|
||||||
|
)
|
||||||
|
).run(String(existingTalent.id));
|
||||||
|
await (
|
||||||
|
await database.prepare('DELETE FROM skill_chip_talents WHERE id=?')
|
||||||
|
).run(String(existingTalent.id));
|
||||||
}
|
}
|
||||||
await saveConditionalRuleInTransaction(
|
|
||||||
database,
|
|
||||||
'skill_chip_talent',
|
|
||||||
talentId,
|
|
||||||
conditionalRule,
|
|
||||||
);
|
|
||||||
await applyIconMutation(
|
await applyIconMutation(
|
||||||
database,
|
database,
|
||||||
'skill_chip',
|
'skill_chip',
|
||||||
@@ -804,6 +794,7 @@ export const actions: Actions = {
|
|||||||
entityId: setId,
|
entityId: setId,
|
||||||
before,
|
before,
|
||||||
after: await snapshot(setId),
|
after: await snapshot(setId),
|
||||||
|
database,
|
||||||
});
|
});
|
||||||
await database.exec('COMMIT');
|
await database.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -816,33 +807,163 @@ export const actions: Actions = {
|
|||||||
resultToken: randomUUID(),
|
resultToken: randomUUID(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
savePrimaryProfile: async ({ request, cookies }) => {
|
||||||
|
const operator = await requirePermission(
|
||||||
|
await getSessionUser(cookies),
|
||||||
|
'data.manage',
|
||||||
|
);
|
||||||
|
const database = await getDatabase();
|
||||||
|
const form = await request.formData();
|
||||||
|
const expectedStatIds = await getSkillChipPrimaryAttributeIds(database);
|
||||||
|
const normalized = normalizeSkillChipProfiles(
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(String(form.get('profiles') ?? ''));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
SKILL_CHIP_QUALITIES,
|
||||||
|
{ expectedStatIds, minimumOptions: 4 },
|
||||||
|
);
|
||||||
|
if (normalized.error)
|
||||||
|
return fail(400, { skillChipPrimaryProfileError: normalized.error });
|
||||||
|
await database.exec('BEGIN IMMEDIATE');
|
||||||
|
try {
|
||||||
|
const before = await getSkillChipPrimaryProfiles(database);
|
||||||
|
if (JSON.stringify(before) !== String(form.get('baseProfiles') ?? '')) {
|
||||||
|
await database.exec('ROLLBACK');
|
||||||
|
if (JSON.stringify(before) === JSON.stringify(normalized.profiles))
|
||||||
|
return {
|
||||||
|
skillChipPrimaryProfileSaved: true,
|
||||||
|
resultToken: randomUUID(),
|
||||||
|
};
|
||||||
|
return fail(409, {
|
||||||
|
skillChipPrimaryProfileError:
|
||||||
|
'The shared primary profile changed after this editor opened. Review the latest values and try again.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await replaceSkillChipPrimaryProfiles(database, normalized.profiles);
|
||||||
|
await refreshAllFocusReadiness(database);
|
||||||
|
const after = await getSkillChipPrimaryProfiles(database);
|
||||||
|
await recordIsacAudit({
|
||||||
|
actorId: operator.id,
|
||||||
|
category: 'game_data',
|
||||||
|
action: 'skill_chip_primary_profile.updated',
|
||||||
|
entityId: 'skill-chip-primary-profile',
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
database,
|
||||||
|
});
|
||||||
|
await database.exec('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await database.exec('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return { skillChipPrimaryProfileSaved: true, resultToken: randomUUID() };
|
||||||
|
},
|
||||||
|
saveSecondaryPool: async ({ request, cookies }) => {
|
||||||
|
const operator = await requirePermission(
|
||||||
|
await getSessionUser(cookies),
|
||||||
|
'data.manage',
|
||||||
|
);
|
||||||
|
const database = await getDatabase();
|
||||||
|
const form = await request.formData();
|
||||||
|
const normalized = normalizeSkillChipProfiles(
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(String(form.get('profiles') ?? ''));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
SKILL_CHIP_SECONDARY_QUALITIES,
|
||||||
|
{ minimumOptions: 2 },
|
||||||
|
);
|
||||||
|
if (normalized.error)
|
||||||
|
return fail(400, { skillChipSecondaryPoolError: normalized.error });
|
||||||
|
const statIds = normalized.profiles.map((profile) => profile.statId);
|
||||||
|
const validStats = await (
|
||||||
|
await database.prepare(
|
||||||
|
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`,
|
||||||
|
)
|
||||||
|
).get(...statIds);
|
||||||
|
if (Number(validStats?.count) !== statIds.length)
|
||||||
|
return fail(400, {
|
||||||
|
skillChipSecondaryPoolError:
|
||||||
|
'One or more selected Attributes are unavailable.',
|
||||||
|
});
|
||||||
|
await database.exec('BEGIN IMMEDIATE');
|
||||||
|
try {
|
||||||
|
const before = await getSkillChipSecondaryPool(database);
|
||||||
|
if (JSON.stringify(before) !== String(form.get('baseProfiles') ?? '')) {
|
||||||
|
await database.exec('ROLLBACK');
|
||||||
|
if (JSON.stringify(before) === JSON.stringify(normalized.profiles))
|
||||||
|
return {
|
||||||
|
skillChipSecondaryPoolSaved: true,
|
||||||
|
resultToken: randomUUID(),
|
||||||
|
};
|
||||||
|
return fail(409, {
|
||||||
|
skillChipSecondaryPoolError:
|
||||||
|
'The shared secondary pool changed after this editor opened. Review the latest values and try again.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await replaceSkillChipSecondaryPool(database, normalized.profiles);
|
||||||
|
await refreshAllFocusReadiness(database);
|
||||||
|
const after = await getSkillChipSecondaryPool(database);
|
||||||
|
await recordIsacAudit({
|
||||||
|
actorId: operator.id,
|
||||||
|
category: 'game_data',
|
||||||
|
action: 'skill_chip_secondary_pool.updated',
|
||||||
|
entityId: 'skill-chip-secondary-pool',
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
database,
|
||||||
|
});
|
||||||
|
await database.exec('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await database.exec('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return { skillChipSecondaryPoolSaved: true, resultToken: randomUUID() };
|
||||||
|
},
|
||||||
archive: async ({ request, cookies }) => {
|
archive: async ({ request, cookies }) => {
|
||||||
const operator = await requirePermission(
|
const operator = await requirePermission(
|
||||||
await getSessionUser(cookies),
|
await getSessionUser(cookies),
|
||||||
'data.manage',
|
'data.manage',
|
||||||
);
|
);
|
||||||
const id = String((await request.formData()).get('id') ?? '');
|
const id = String((await request.formData()).get('id') ?? '');
|
||||||
const before = await snapshot(id);
|
|
||||||
if (!before.record)
|
|
||||||
return fail(404, { skillChipError: 'Skill Chip set not found.' });
|
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
await (
|
await database.exec('BEGIN IMMEDIATE');
|
||||||
await database.prepare(
|
try {
|
||||||
'UPDATE skill_chip_sets SET active=0,updated_at=? WHERE id=?',
|
const before = await snapshot(id);
|
||||||
)
|
if (!before.record) {
|
||||||
).run(new Date().toISOString(), id);
|
await database.exec('ROLLBACK');
|
||||||
await refreshAbilityFocuses(
|
return fail(404, { skillChipError: 'Skill Chip set not found.' });
|
||||||
database,
|
}
|
||||||
String(before.record.specialization_ability_id),
|
await (
|
||||||
);
|
await database.prepare(
|
||||||
await recordIsacAudit({
|
'UPDATE skill_chip_sets SET active=0,updated_at=? WHERE id=?',
|
||||||
actorId: operator.id,
|
)
|
||||||
category: 'game_data',
|
).run(new Date().toISOString(), id);
|
||||||
action: 'skill_chip.archived',
|
await refreshAbilityFocuses(
|
||||||
entityId: id,
|
database,
|
||||||
before,
|
String(before.record.specialization_ability_id),
|
||||||
after: await snapshot(id),
|
);
|
||||||
});
|
await recordIsacAudit({
|
||||||
|
actorId: operator.id,
|
||||||
|
category: 'game_data',
|
||||||
|
action: 'skill_chip.archived',
|
||||||
|
entityId: id,
|
||||||
|
before,
|
||||||
|
after: await snapshot(id),
|
||||||
|
database,
|
||||||
|
});
|
||||||
|
await database.exec('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await database.exec('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return { skillChipArchived: true };
|
return { skillChipArchived: true };
|
||||||
},
|
},
|
||||||
unarchive: async ({ request, cookies }) => {
|
unarchive: async ({ request, cookies }) => {
|
||||||
@@ -851,50 +972,63 @@ export const actions: Actions = {
|
|||||||
'data.manage',
|
'data.manage',
|
||||||
);
|
);
|
||||||
const id = String((await request.formData()).get('id') ?? '');
|
const id = String((await request.formData()).get('id') ?? '');
|
||||||
const before = await snapshot(id);
|
|
||||||
if (!before.record)
|
|
||||||
return fail(404, { skillChipError: 'Skill Chip set not found.' });
|
|
||||||
const abilityId = String(before.record.specialization_ability_id);
|
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
if (
|
await database.exec('BEGIN IMMEDIATE');
|
||||||
!(await (
|
try {
|
||||||
await database.prepare(
|
const before = await snapshot(id);
|
||||||
'SELECT id FROM specialization_abilities WHERE id=? AND active=1',
|
if (!before.record) {
|
||||||
)
|
await database.exec('ROLLBACK');
|
||||||
).get(abilityId))
|
return fail(404, { skillChipError: 'Skill Chip set not found.' });
|
||||||
) {
|
}
|
||||||
return fail(400, {
|
const abilityId = String(before.record.specialization_ability_id);
|
||||||
skillChipError:
|
if (
|
||||||
'The assigned specialization ability must be active before restoring this Skill Chip.',
|
!(await (
|
||||||
});
|
await database.prepare(
|
||||||
}
|
'SELECT id FROM specialization_abilities WHERE id=? AND active=1',
|
||||||
const assignedCount = Number(
|
)
|
||||||
(
|
).get(abilityId))
|
||||||
await (
|
) {
|
||||||
await database.prepare(`SELECT COUNT(*) AS count FROM skill_chip_sets
|
await database.exec('ROLLBACK');
|
||||||
|
return fail(400, {
|
||||||
|
skillChipError:
|
||||||
|
'The assigned specialization ability must be active before restoring this Skill Chip.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const assignedCount = Number(
|
||||||
|
(
|
||||||
|
await (
|
||||||
|
await database.prepare(`SELECT COUNT(*) AS count FROM skill_chip_sets
|
||||||
WHERE specialization_ability_id=? AND active=1 AND id<>?`)
|
WHERE specialization_ability_id=? AND active=1 AND id<>?`)
|
||||||
).get(abilityId, id)
|
).get(abilityId, id)
|
||||||
)?.count ?? 0,
|
)?.count ?? 0,
|
||||||
);
|
);
|
||||||
if (assignedCount >= 3)
|
if (assignedCount >= 3) {
|
||||||
return fail(409, {
|
await database.exec('ROLLBACK');
|
||||||
skillChipError:
|
return fail(409, {
|
||||||
'That ability already has all three active Skill Chips assigned.',
|
skillChipError:
|
||||||
|
'That ability already has all three active Skill Chips assigned.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
'UPDATE skill_chip_sets SET active=1,updated_at=? WHERE id=?',
|
||||||
|
)
|
||||||
|
).run(new Date().toISOString(), id);
|
||||||
|
await refreshAbilityFocuses(database, abilityId);
|
||||||
|
await recordIsacAudit({
|
||||||
|
actorId: operator.id,
|
||||||
|
category: 'game_data',
|
||||||
|
action: 'skill_chip.unarchived',
|
||||||
|
entityId: id,
|
||||||
|
before,
|
||||||
|
after: await snapshot(id),
|
||||||
|
database,
|
||||||
});
|
});
|
||||||
await (
|
await database.exec('COMMIT');
|
||||||
await database.prepare(
|
} catch (error) {
|
||||||
'UPDATE skill_chip_sets SET active=1,updated_at=? WHERE id=?',
|
await database.exec('ROLLBACK');
|
||||||
)
|
throw error;
|
||||||
).run(new Date().toISOString(), id);
|
}
|
||||||
await refreshAbilityFocuses(database, abilityId);
|
|
||||||
await recordIsacAudit({
|
|
||||||
actorId: operator.id,
|
|
||||||
category: 'game_data',
|
|
||||||
action: 'skill_chip.unarchived',
|
|
||||||
entityId: id,
|
|
||||||
before,
|
|
||||||
after: await snapshot(id),
|
|
||||||
});
|
|
||||||
return { skillChipUnarchived: true };
|
return { skillChipUnarchived: true };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||||
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
|
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
|
||||||
|
import SkillChipSharedProfiles from '$lib/components/SkillChipSharedProfiles.svelte';
|
||||||
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
@@ -22,7 +23,7 @@
|
|||||||
clauses:[{key:'primary',label:'Primary',event:'skill_hit',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'assigned_ability',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{minimum:{value:0},maximum:{value:0}}}]}]
|
clauses:[{key:'primary',label:'Primary',event:'skill_hit',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'assigned_ability',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{minimum:{value:0},maximum:{value:0}}}]}]
|
||||||
});
|
});
|
||||||
const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() });
|
const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() });
|
||||||
const blank = () => ({ id: '', name: '', abilityId: '', iconUrl: '', renderUrl: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, primaryStatIds: [], secondaryStatIds: [], talent: blankTalent() });
|
const blank = () => ({ id: '', name: '', abilityId: '', iconUrl: '', renderUrl: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, hasTalent: false, talentOpen: false, talent: blankTalent() });
|
||||||
let editing = $state<any | null>(null);
|
let editing = $state<any | null>(null);
|
||||||
let highEndCount = $state('0');
|
let highEndCount = $state('0');
|
||||||
let handledResultToken = $state('');
|
let handledResultToken = $state('');
|
||||||
@@ -65,9 +66,9 @@
|
|||||||
...item,
|
...item,
|
||||||
abilityId: String(item.specialization_ability_id),
|
abilityId: String(item.specialization_ability_id),
|
||||||
twoPiece: item.twoPiece ? { ...item.twoPiece } : { stat_attribute_id: '', min_value: '', max_value: '' },
|
twoPiece: item.twoPiece ? { ...item.twoPiece } : { stat_attribute_id: '', min_value: '', max_value: '' },
|
||||||
primaryStatIds: [...item.primaryStatIds],
|
hasTalent: Boolean(item.talent),
|
||||||
secondaryStatIds: [...item.secondaryStatIds],
|
talentOpen: false,
|
||||||
talent: item.talent ? { ...item.talent, conditionalRule: item.talent.conditionalRule ?? blankRule() } : blankTalent()
|
talent: item.talent ? structuredClone({ ...item.talent, conditionalRule: item.talent.conditionalRule ?? blankRule() }) : blankTalent()
|
||||||
};
|
};
|
||||||
highEndCount = '0';
|
highEndCount = '0';
|
||||||
};
|
};
|
||||||
@@ -86,17 +87,30 @@
|
|||||||
editing = form.skillChipMode === 'add' ? blank() : null;
|
editing = form.skillChipMode === 'add' ? blank() : null;
|
||||||
highEndCount = '0';
|
highEndCount = '0';
|
||||||
}
|
}
|
||||||
|
if (form?.skillChipError && editing?.hasTalent) editing.talentOpen = true;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Skill Chips — SHD.build admin</title></svelte:head>
|
<svelte:head><title>Skill Chips — SHD.build admin</title></svelte:head>
|
||||||
<div class="module-toolbar"><div><p class="eyebrow">SKILL MODIFICATION SYNERGY REGISTRY</p><h2>SKILL CHIPS</h2><p>Configure Superior and High-end chip rolls, 2-piece bonuses, and quality-scaled 3-piece talents.</p></div><button class="primary" onclick={() => editing = blank()}>ADD SKILL CHIP <span>+</span></button></div>
|
<div class="module-toolbar"><div><p class="eyebrow">SKILL MODIFICATION SYNERGY REGISTRY</p><h2>SKILL CHIPS</h2><p>Configure shared quality rolls, ability-linked chip sets, 2-piece bonuses, and optional 3-piece talents.</p></div><button class="primary" onclick={() => editing = blank()}>ADD SKILL CHIP <span>+</span></button></div>
|
||||||
|
|
||||||
|
<SkillChipSharedProfiles
|
||||||
|
primaryProfiles={data.primaryProfiles}
|
||||||
|
primaryProfileBase={data.primaryProfileBase}
|
||||||
|
secondaryPool={data.secondaryPool}
|
||||||
|
stats={data.stats}
|
||||||
|
primaryError={form?.skillChipPrimaryProfileError}
|
||||||
|
secondaryError={form?.skillChipSecondaryPoolError}
|
||||||
|
primarySaved={form?.skillChipPrimaryProfileSaved}
|
||||||
|
secondarySaved={form?.skillChipSecondaryPoolSaved}
|
||||||
|
resultToken={form?.resultToken}
|
||||||
|
/>
|
||||||
|
|
||||||
<KpiFilters active={data.filter} items={[
|
<KpiFilters active={data.filter} items={[
|
||||||
{value:'',label:'TOTAL SETS',count:data.summary.total,description:'Show all definitions'},
|
{value:'',label:'TOTAL SETS',count:data.summary.total,description:'Show all definitions'},
|
||||||
{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'},
|
{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'},
|
||||||
{value:'assigned',label:'ABILITY LINKED',count:data.summary.assigned,description:'Assigned to a class ability'},
|
{value:'assigned',label:'ABILITY LINKED',count:data.summary.assigned,description:'Assigned to a class ability'},
|
||||||
{value:'configured',label:'CONFIGURED',count:data.summary.configured,description:'Complete 2-piece and 3-piece effects'},
|
{value:'configured',label:'CONFIGURED',count:data.summary.configured,description:'Complete 2-piece bonus data'},
|
||||||
{value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Hidden definitions'}
|
{value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Hidden definitions'}
|
||||||
]}/>
|
]}/>
|
||||||
|
|
||||||
@@ -106,10 +120,11 @@
|
|||||||
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY CHIP SET' : 'NEW CHIP SET'}</p><h3>{editing.id ? editing.name : 'SKILL CHIP DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
|
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY CHIP SET' : 'NEW CHIP SET'}</p><h3>{editing.id ? editing.name : 'SKILL CHIP DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
|
||||||
<PendingForm action="?/save" enctype="multipart/form-data">
|
<PendingForm action="?/save" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="id" value={editing.id}/>
|
<input type="hidden" name="id" value={editing.id}/>
|
||||||
|
<input type="hidden" name="hasTalent" value={editing.hasTalent ? '1' : '0'}/>
|
||||||
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)}/>
|
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)}/>
|
||||||
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="SKILL CHIP RENDER" resetToken={renderResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Only these qualities participate in chip scaling.</small></div></div>
|
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="SKILL CHIP RENDER" resetToken={renderResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b>STANDARD</b><b>SPECIALIZED</b><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Shared profiles define primary and secondary ranges.</small></div></div>
|
||||||
<section class="skill-chip-ability-assignment">
|
<section class="skill-chip-ability-assignment">
|
||||||
<div class="selection-head"><div><span>SPECIALIZATION ABILITY</span><small>EXCLUSIVE RELATIONSHIP · THREE ACTIVE SKILL CHIPS PER ABILITY</small></div><b>{editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 ASSIGNED` : 'REQUIRED'}</b></div>
|
<div class="selection-head"><div><span>OWNING ABILITY</span><small>THREE ACTIVE DEFINITIONS · ABILITY SHARING IS CONFIGURED IN SPECIALIZATIONS</small></div><b>{editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 OWNED` : 'REQUIRED'}</b></div>
|
||||||
<SearchableAssignment items={data.abilities} fieldName="abilityId" mode="single" compact subjectLabel="SPECIALIZATION ABILITY" searchPlaceholder="Search abilities or specializations…" emptySelectionLabel="NO ABILITY ASSIGNED" bind:value={editing.abilityId}/>
|
<SearchableAssignment items={data.abilities} fieldName="abilityId" mode="single" compact subjectLabel="SPECIALIZATION ABILITY" searchPlaceholder="Search abilities or specializations…" emptySelectionLabel="NO ABILITY ASSIGNED" bind:value={editing.abilityId}/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -118,15 +133,14 @@
|
|||||||
<div class="skill-chip-bonus-grid"><div><SearchableAssignment items={data.stats} fieldName="twoPieceStatId" mode="single" subjectLabel="BONUS ATTRIBUTE" searchPlaceholder="Search attributes…" emptySelectionLabel="NO BONUS ATTRIBUTE SELECTED" bind:value={editing.twoPiece.stat_attribute_id}/></div><div class="skill-chip-range-fields"><label class="field">MINIMUM VALUE<input name="twoPieceMin" type="number" step="any" bind:value={editing.twoPiece.min_value} required/></label><label class="field">MAXIMUM VALUE<input name="twoPieceMax" type="number" step="any" bind:value={editing.twoPiece.max_value} required/></label></div></div>
|
<div class="skill-chip-bonus-grid"><div><SearchableAssignment items={data.stats} fieldName="twoPieceStatId" mode="single" subjectLabel="BONUS ATTRIBUTE" searchPlaceholder="Search attributes…" emptySelectionLabel="NO BONUS ATTRIBUTE SELECTED" bind:value={editing.twoPiece.stat_attribute_id}/></div><div class="skill-chip-range-fields"><label class="field">MINIMUM VALUE<input name="twoPieceMin" type="number" step="any" bind:value={editing.twoPiece.min_value} required/></label><label class="field">MAXIMUM VALUE<input name="twoPieceMax" type="number" step="any" bind:value={editing.twoPiece.max_value} required/></label></div></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="skill-chip-pools">
|
|
||||||
<div><div class="selection-head"><div><span>PRIMARY STAT POOL</span><small>ONE RANDOM STAT · USES ATTRIBUTE SKILL CHIP RANGE</small></div><b>{editing.primaryStatIds.length}</b></div><SearchableAssignment items={data.rollStats} fieldName="primaryStatIds" subjectLabel="PRIMARY STATS" searchPlaceholder="Search Skill Chip stats…" excludedIds={editing.secondaryStatIds} bind:selected={editing.primaryStatIds}/></div>
|
|
||||||
<div><div class="selection-head"><div><span>SECONDARY STAT POOL</span><small>TWO DISTINCT RANDOM STATS · USE ATTRIBUTE RANGES</small></div><b>{editing.secondaryStatIds.length}</b></div><SearchableAssignment items={data.rollStats} fieldName="secondaryStatIds" subjectLabel="SECONDARY STATS" searchPlaceholder="Search Skill Chip stats…" excludedIds={editing.primaryStatIds} bind:selected={editing.secondaryStatIds}/></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="skill-chip-talent">
|
<section class="skill-chip-talent">
|
||||||
<div class="selection-head"><div><span>3-PIECE TALENT</span><small>QUALITY-SCALED OUTCOMES · TYPED TRIGGER AND DESCRIPTION</small></div><b>{previewOutcomes().length} OUTCOME{previewOutcomes().length === 1 ? '' : 'S'}</b></div>
|
<div class="selection-head"><div><span>3-PIECE TALENT</span><small>OPTIONAL SET BONUS · QUALITY-SCALED WHEN ASSIGNED</small></div><SegmentedChoice legend="TALENT ASSIGNMENT" name="talent-assignment" options={[{value:'0',label:'NOT ASSIGNED'},{value:'1',label:'ASSIGNED'}]} value={editing.hasTalent ? '1' : '0'} onchange={(values) => { editing.hasTalent = values[0] === '1'; if (editing.hasTalent) editing.talentOpen = true; }}/></div>
|
||||||
|
{#if editing.hasTalent}
|
||||||
|
<details class="modeling-disclosure skill-chip-talent-disclosure" bind:open={editing.talentOpen}>
|
||||||
|
<summary><span><b>{editing.talent.name || 'TALENT WORKSPACE'}</b><small>{previewOutcomes().length} QUALITY-SCALED OUTCOME{previewOutcomes().length === 1 ? '' : 'S'}</small></span><strong>{editing.talentOpen ? 'COLLAPSE' : 'EXPAND'}</strong></summary>
|
||||||
|
<div class="modeling-content">
|
||||||
<div class="skill-chip-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label><label class="field">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={editing.talent.description_template} placeholder="While active, gain +{1} Skill Damage…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for scaled effect values and units.</small></label></div>
|
<div class="skill-chip-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label><label class="field">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={editing.talent.description_template} placeholder="While active, gain +{1} Skill Damage…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for scaled effect values and units.</small></label></div>
|
||||||
<div class="skill-chip-preview"><SegmentedChoice legend="QUALITY MIX PREVIEW" name="qualityPreview" options={qualitySteps} bind:value={highEndCount}/><div><span>{3 - Number(highEndCount)} SUPERIOR · {highEndCount} HIGH-END</span><p>{talentPreview() || 'Talent description preview'}</p></div></div>
|
<div class="skill-chip-preview"><SegmentedChoice legend="QUALITY MIX PREVIEW" name="qualityPreview" options={qualitySteps} bind:value={highEndCount}/><div><span>{3 - Number(highEndCount)} NON-HIGH-END · {highEndCount} HIGH-END</span><p>{talentPreview() || 'Talent description preview'}</p></div></div>
|
||||||
<MechanicProgramEditor
|
<MechanicProgramEditor
|
||||||
attributes={data.stats}
|
attributes={data.stats}
|
||||||
allowAssignedAbility
|
allowAssignedAbility
|
||||||
@@ -139,9 +153,12 @@
|
|||||||
}}
|
}}
|
||||||
bind:rule={editing.talent.conditionalRule}
|
bind:rule={editing.talent.conditionalRule}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{:else}<p class="console-note">This set has no 3-piece Talent. Its 2-piece bonus remains active when the requirement is met.</p>{#if editing.talent.id}<label class="skill-chip-talent-removal"><input type="checkbox" name="confirmTalentRemoval" value="REMOVE" required/> CONFIRM REMOVAL OF THE EXISTING TALENT AND MECHANIC PROGRAM</label>{/if}{/if}
|
||||||
</section>
|
</section>
|
||||||
{#if form?.skillChipError}<p class="error">{form.skillChipError}</p>{/if}
|
{#if form?.skillChipError}<p class="error">{form.skillChipError}</p>{/if}
|
||||||
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction={Number(editing.active) ? '?/archive' : '?/unarchive'} name="id" value={editing.id}>{Number(editing.active) ? 'ARCHIVE CHIP SET' : 'UNARCHIVE CHIP SET'}</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!editing.abilityId || !editing.primaryStatIds.length || editing.secondaryStatIds.length < 2 || !data.rollStats.length}>SAVE SKILL CHIP →</button></div></div>
|
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction={Number(editing.active) ? '?/archive' : '?/unarchive'} name="id" value={editing.id}>{Number(editing.active) ? 'ARCHIVE CHIP SET' : 'UNARCHIVE CHIP SET'}</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!editing.abilityId}>SAVE SKILL CHIP →</button></div></div>
|
||||||
</PendingForm>
|
</PendingForm>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -150,9 +167,9 @@
|
|||||||
|
|
||||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||||
<div class="dashboard-table">
|
<div class="dashboard-table">
|
||||||
<div class="dashboard-row skill-chip-row table-heading"><SortableColumnHeader label="SKILL CHIP" column="name" sort={data.sort}/><SortableColumnHeader label="ABILITY" column="ability" sort={data.sort}/><span>QUALITY</span><SortableColumnHeader label="2-PIECE BONUS" column="twoPiece" sort={data.sort}/><SortableColumnHeader label="3-PIECE TALENT" column="talent" sort={data.sort}/><SortableColumnHeader label="ROLL POOLS" column="rollPools" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
<div class="dashboard-row skill-chip-row table-heading"><SortableColumnHeader label="SKILL CHIP" column="name" sort={data.sort}/><SortableColumnHeader label="ABILITY" column="ability" sort={data.sort}/><span>QUALITY</span><SortableColumnHeader label="2-PIECE BONUS" column="twoPiece" sort={data.sort}/><SortableColumnHeader label="3-PIECE TALENT" column="talent" sort={data.sort}/><span>ROLL PROFILE</span><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
||||||
{#each data.sets as item}
|
{#each data.sets as item}
|
||||||
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>EXCLUSIVE ABILITY</small></div><span class="skill-chip-quality-pair"><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT CONFIGURED'}</strong><small>{item.talent?.conditionalRule?.clauses?.length || item.pending ? 'MODELED · QUALITY SCALED' : 'CONFIG REQUIRED'}</small></div><span>{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
|
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>OWNING ABILITY</small></div><span class="skill-chip-quality-pair"><b>STD</b><b>SP</b><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT ASSIGNED'}</strong><small>{item.talent ? item.talent.conditionalRule?.clauses?.length || item.pending ? 'MODELED · QUALITY SCALED' : 'CONFIG REQUIRED' : 'OPTIONAL'}</small></div><span>SHARED · 4 PRIMARY · {data.secondaryPool.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
|
||||||
{#if editing?.id === item.id}<RegistryInlineEditor id={`skill-chip-editor-${item.id}`} label={`Edit ${item.name}`}>{@render skillChipEditor()}</RegistryInlineEditor>{/if}
|
{#if editing?.id === item.id}<RegistryInlineEditor id={`skill-chip-editor-${item.id}`} label={`Edit ${item.name}`}>{@render skillChipEditor()}</RegistryInlineEditor>{/if}
|
||||||
{:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first Superior and High-end Skill Chip definition.</span></div>{/each}
|
{:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first ability-linked Skill Chip definition.</span></div>{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => {
|
||||||
|
const database = {
|
||||||
|
exec: vi.fn(async () => undefined),
|
||||||
|
prepare: vi.fn(),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
database,
|
||||||
|
getDatabase: vi.fn(async () => database),
|
||||||
|
getPrimaryAttributeIds: vi.fn(),
|
||||||
|
getPrimaryProfiles: vi.fn(),
|
||||||
|
getSecondaryPool: vi.fn(),
|
||||||
|
recordIsacAudit: vi.fn(),
|
||||||
|
refreshFocus: vi.fn(),
|
||||||
|
replacePrimaryProfiles: vi.fn(),
|
||||||
|
replaceSecondaryPool: vi.fn(),
|
||||||
|
requirePermission: vi.fn(async (user) => user),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('$lib/server/auth/session', () => ({
|
||||||
|
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/auth/authorization', () => ({
|
||||||
|
requireDataView: vi.fn(async (user) => user),
|
||||||
|
requirePermission: mocks.requirePermission,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/database', () => ({
|
||||||
|
getDatabase: mocks.getDatabase,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/audit', () => ({
|
||||||
|
auditRecord: vi.fn(),
|
||||||
|
recordIsacAudit: mocks.recordIsacAudit,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/data-moderation', () => ({
|
||||||
|
getPendingEntities: vi.fn(async () => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/skill-chip-profiles', () => ({
|
||||||
|
getSkillChipPrimaryAttributeIds: mocks.getPrimaryAttributeIds,
|
||||||
|
getSkillChipPrimaryProfiles: mocks.getPrimaryProfiles,
|
||||||
|
getSkillChipSecondaryPool: mocks.getSecondaryPool,
|
||||||
|
replaceSkillChipPrimaryProfiles: mocks.replacePrimaryProfiles,
|
||||||
|
replaceSkillChipSecondaryPool: mocks.replaceSecondaryPool,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/specialization-readiness', () => ({
|
||||||
|
refreshAbilityFocuses: vi.fn(),
|
||||||
|
refreshSpecializationFocusReady: mocks.refreshFocus,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/managed-images', () => ({
|
||||||
|
applyGameDataRender: vi.fn(),
|
||||||
|
ManagedImageValidationError: class extends Error {},
|
||||||
|
managedImageUrl: vi.fn(() => ''),
|
||||||
|
processManagedImage: vi.fn(),
|
||||||
|
readGameDataRenderMutation: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { actions } from './+page.server';
|
||||||
|
|
||||||
|
const primaryIds = [
|
||||||
|
'skill-duration',
|
||||||
|
'skill-radius',
|
||||||
|
'skill-cooldown-recovery',
|
||||||
|
'skill-intensity',
|
||||||
|
];
|
||||||
|
const primaryQualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
|
||||||
|
const secondaryQualities = ['Specialized', 'Superior', 'High-end'];
|
||||||
|
|
||||||
|
const profiles = (ids: string[], qualities: string[], offset = 0) =>
|
||||||
|
ids.map((statId, profileIndex) => ({
|
||||||
|
statId,
|
||||||
|
values: Object.fromEntries(
|
||||||
|
qualities.map((quality, qualityIndex) => [
|
||||||
|
quality,
|
||||||
|
{
|
||||||
|
min: offset + profileIndex * 10 + qualityIndex + 1,
|
||||||
|
max: offset + profileIndex * 10 + qualityIndex + 2,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const primaryAfter = profiles(primaryIds, primaryQualities, 10);
|
||||||
|
const secondaryIds = ['critical-hit-chance', 'critical-hit-damage'];
|
||||||
|
const secondaryAfter = profiles(secondaryIds, secondaryQualities, 20);
|
||||||
|
|
||||||
|
const actionRequest = (action: string, payload: unknown, base: unknown) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set('profiles', JSON.stringify(payload));
|
||||||
|
form.set('baseProfiles', JSON.stringify(base));
|
||||||
|
return new Request(`http://localhost/isac/skillchips?/${action}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cases = [
|
||||||
|
{
|
||||||
|
actionName: 'savePrimaryProfile' as const,
|
||||||
|
payload: primaryAfter,
|
||||||
|
getProfiles: mocks.getPrimaryProfiles,
|
||||||
|
replaceProfiles: mocks.replacePrimaryProfiles,
|
||||||
|
before: profiles(primaryIds, primaryQualities),
|
||||||
|
after: profiles(primaryIds, primaryQualities, 30),
|
||||||
|
auditAction: 'skill_chip_primary_profile.updated',
|
||||||
|
entityId: 'skill-chip-primary-profile',
|
||||||
|
savedKey: 'skillChipPrimaryProfileSaved',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actionName: 'saveSecondaryPool' as const,
|
||||||
|
payload: secondaryAfter,
|
||||||
|
getProfiles: mocks.getSecondaryPool,
|
||||||
|
replaceProfiles: mocks.replaceSecondaryPool,
|
||||||
|
before: profiles(secondaryIds, secondaryQualities),
|
||||||
|
after: profiles(secondaryIds, secondaryQualities, 40),
|
||||||
|
auditAction: 'skill_chip_secondary_pool.updated',
|
||||||
|
entityId: 'skill-chip-secondary-pool',
|
||||||
|
savedKey: 'skillChipSecondaryPoolSaved',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('Skill Chip shared profile actions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.getPrimaryAttributeIds.mockResolvedValue(primaryIds);
|
||||||
|
mocks.database.prepare.mockImplementation(async (sql: string) => ({
|
||||||
|
all: vi.fn(async () =>
|
||||||
|
sql.includes('specialization_focuses') ? [{ id: 'focus-1' }] : [],
|
||||||
|
),
|
||||||
|
get: vi.fn(async (...statIds: unknown[]) => ({ count: statIds.length })),
|
||||||
|
run: vi.fn(),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const testCase of cases) {
|
||||||
|
it(`${testCase.actionName} audits complete evidence and commits its transaction`, async () => {
|
||||||
|
testCase.getProfiles
|
||||||
|
.mockResolvedValueOnce(testCase.before)
|
||||||
|
.mockResolvedValueOnce(testCase.after);
|
||||||
|
|
||||||
|
const result = await actions[testCase.actionName]?.({
|
||||||
|
cookies: {} as never,
|
||||||
|
request: actionRequest(
|
||||||
|
testCase.actionName,
|
||||||
|
testCase.payload,
|
||||||
|
testCase.before,
|
||||||
|
),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
[testCase.savedKey]: true,
|
||||||
|
resultToken: expect.any(String),
|
||||||
|
});
|
||||||
|
expect(mocks.requirePermission).toHaveBeenCalledWith(
|
||||||
|
{ id: 'operator-1' },
|
||||||
|
'data.manage',
|
||||||
|
);
|
||||||
|
expect(testCase.getProfiles).toHaveBeenNthCalledWith(1, mocks.database);
|
||||||
|
expect(testCase.replaceProfiles).toHaveBeenCalledWith(
|
||||||
|
mocks.database,
|
||||||
|
testCase.payload,
|
||||||
|
);
|
||||||
|
expect(testCase.getProfiles).toHaveBeenNthCalledWith(2, mocks.database);
|
||||||
|
expect(mocks.refreshFocus).toHaveBeenCalledWith(
|
||||||
|
mocks.database,
|
||||||
|
'focus-1',
|
||||||
|
);
|
||||||
|
expect(mocks.recordIsacAudit).toHaveBeenCalledWith({
|
||||||
|
actorId: 'operator-1',
|
||||||
|
category: 'game_data',
|
||||||
|
action: testCase.auditAction,
|
||||||
|
entityId: testCase.entityId,
|
||||||
|
before: testCase.before,
|
||||||
|
after: testCase.after,
|
||||||
|
database: mocks.database,
|
||||||
|
});
|
||||||
|
expect(mocks.database.exec.mock.calls).toEqual([
|
||||||
|
['BEGIN IMMEDIATE'],
|
||||||
|
['COMMIT'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`${testCase.actionName} rolls back and does not commit when audit fails`, async () => {
|
||||||
|
const auditError = new Error('audit insert failed');
|
||||||
|
testCase.getProfiles
|
||||||
|
.mockResolvedValueOnce(testCase.before)
|
||||||
|
.mockResolvedValueOnce(testCase.after);
|
||||||
|
mocks.recordIsacAudit.mockRejectedValueOnce(auditError);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
actions[testCase.actionName]?.({
|
||||||
|
cookies: {} as never,
|
||||||
|
request: actionRequest(
|
||||||
|
testCase.actionName,
|
||||||
|
testCase.payload,
|
||||||
|
testCase.before,
|
||||||
|
),
|
||||||
|
} as never),
|
||||||
|
).rejects.toThrow(auditError);
|
||||||
|
|
||||||
|
expect(mocks.database.exec.mock.calls).toEqual([
|
||||||
|
['BEGIN IMMEDIATE'],
|
||||||
|
['ROLLBACK'],
|
||||||
|
]);
|
||||||
|
expect(mocks.database.exec).not.toHaveBeenCalledWith('COMMIT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`${testCase.actionName} rejects stale baseProfiles without replacing or auditing`, async () => {
|
||||||
|
testCase.getProfiles.mockResolvedValueOnce(testCase.before);
|
||||||
|
|
||||||
|
const result = await actions[testCase.actionName]?.({
|
||||||
|
cookies: {} as never,
|
||||||
|
request: actionRequest(
|
||||||
|
testCase.actionName,
|
||||||
|
testCase.payload,
|
||||||
|
profiles(['stale-profile'], primaryQualities),
|
||||||
|
),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 409 });
|
||||||
|
expect(testCase.replaceProfiles).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.refreshFocus).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.database.exec.mock.calls).toEqual([
|
||||||
|
['BEGIN IMMEDIATE'],
|
||||||
|
['ROLLBACK'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('does not open a database when data.manage permission is denied', async () => {
|
||||||
|
const permissionError = new Error('Forbidden');
|
||||||
|
mocks.requirePermission.mockRejectedValueOnce(permissionError);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
actions.savePrimaryProfile?.({
|
||||||
|
cookies: {} as never,
|
||||||
|
request: actionRequest('savePrimaryProfile', primaryAfter, []),
|
||||||
|
} as never),
|
||||||
|
).rejects.toThrow(permissionError);
|
||||||
|
|
||||||
|
expect(mocks.requirePermission).toHaveBeenCalledWith(
|
||||||
|
{ id: 'operator-1' },
|
||||||
|
'data.manage',
|
||||||
|
);
|
||||||
|
expect(mocks.getDatabase).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.database.exec).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user