feat: expand ISAC content modeling
This commit is contained in:
+48
-30
@@ -769,15 +769,12 @@ CREATE TABLE IF NOT EXISTS stat_attributes (
|
||||
weapon_min REAL,
|
||||
weapon_max REAL,
|
||||
weapon_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50),
|
||||
skill_chip_min REAL,
|
||||
skill_chip_max REAL,
|
||||
cap_value REAL,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK((gear_min IS NULL AND gear_max IS NULL) OR (gear_min IS NOT NULL AND gear_max IS NOT NULL AND gear_min <= gear_max)),
|
||||
CHECK((weapon_min IS NULL AND weapon_max IS NULL) OR (weapon_min IS NOT NULL AND weapon_max IS NOT NULL AND weapon_min <= weapon_max)),
|
||||
CHECK((skill_chip_min IS NULL AND skill_chip_max IS NULL) OR (skill_chip_min IS NOT NULL AND skill_chip_max IS NOT NULL AND skill_chip_min <= skill_chip_max)),
|
||||
CHECK(cap_value IS NULL OR cap_value >= 0)
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS stat_attributes_name_unit_nocase_idx ON stat_attributes(name COLLATE NOCASE, unit);
|
||||
@@ -906,6 +903,7 @@ CREATE TABLE IF NOT EXISTS conditional_effect_outcomes (
|
||||
delivery_mode TEXT NOT NULL DEFAULT 'instant' CHECK(delivery_mode IN ('instant','continuous')),
|
||||
delivery_duration_seconds REAL CHECK(delivery_duration_seconds IS NULL OR delivery_duration_seconds>0),
|
||||
targeting_json TEXT,
|
||||
probability_percent REAL CHECK(probability_percent IS NULL OR (probability_percent>=0 AND probability_percent<=100)),
|
||||
min_state_value REAL,
|
||||
max_state_value REAL,
|
||||
description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1),
|
||||
@@ -1168,14 +1166,30 @@ CREATE TABLE IF NOT EXISTS skill_chip_two_piece_bonuses (
|
||||
CHECK(max_value >= min_value)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS skill_chip_primary_options (
|
||||
skill_chip_set_id TEXT NOT NULL REFERENCES skill_chip_sets(id) ON DELETE CASCADE,
|
||||
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
|
||||
PRIMARY KEY(skill_chip_set_id, stat_attribute_id)
|
||||
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS skill_chip_primary_quality_values (
|
||||
stat_attribute_id TEXT NOT NULL,
|
||||
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
|
||||
min_value REAL NOT NULL,
|
||||
max_value REAL NOT NULL,
|
||||
PRIMARY KEY(stat_attribute_id,quality),
|
||||
FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_primary_options(stat_attribute_id) ON DELETE CASCADE,
|
||||
CHECK(max_value>=min_value),
|
||||
CHECK(min_value>=0)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS skill_chip_secondary_options (
|
||||
skill_chip_set_id TEXT NOT NULL REFERENCES skill_chip_sets(id) ON DELETE CASCADE,
|
||||
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
|
||||
PRIMARY KEY(skill_chip_set_id, stat_attribute_id)
|
||||
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS skill_chip_secondary_quality_values (
|
||||
stat_attribute_id TEXT NOT NULL,
|
||||
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
|
||||
min_value REAL NOT NULL,
|
||||
max_value REAL NOT NULL,
|
||||
PRIMARY KEY(stat_attribute_id,quality),
|
||||
FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_secondary_options(stat_attribute_id) ON DELETE CASCADE,
|
||||
CHECK(max_value>=min_value),
|
||||
CHECK(min_value>=0)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS skill_chip_talents (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -1232,19 +1246,21 @@ CREATE TABLE IF NOT EXISTS weapon_mod_quality_values (
|
||||
CHECK(primary_level_20_max>=primary_level_1_max)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS weapon_mod_secondary_options (
|
||||
weapon_mod_id TEXT NOT NULL REFERENCES weapon_mods(id) ON DELETE CASCADE,
|
||||
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
|
||||
PRIMARY KEY(weapon_mod_id,stat_attribute_id)
|
||||
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS weapon_mod_secondary_quality_values (
|
||||
weapon_mod_id TEXT NOT NULL,
|
||||
stat_attribute_id TEXT NOT NULL,
|
||||
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
|
||||
min_value REAL NOT NULL,
|
||||
max_value REAL NOT NULL,
|
||||
PRIMARY KEY(weapon_mod_id,stat_attribute_id,quality),
|
||||
FOREIGN KEY(weapon_mod_id,stat_attribute_id) REFERENCES weapon_mod_secondary_options(weapon_mod_id,stat_attribute_id) ON DELETE CASCADE,
|
||||
CHECK(max_value>=min_value)
|
||||
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
|
||||
level_1_min REAL NOT NULL,
|
||||
level_1_max REAL NOT NULL,
|
||||
level_20_min REAL NOT NULL,
|
||||
level_20_max REAL NOT NULL,
|
||||
PRIMARY KEY(stat_attribute_id,quality),
|
||||
FOREIGN KEY(stat_attribute_id) REFERENCES weapon_mod_secondary_options(stat_attribute_id) ON DELETE CASCADE,
|
||||
CHECK(level_1_max>=level_1_min),
|
||||
CHECK(level_20_max>=level_20_min),
|
||||
CHECK(level_20_min>=level_1_min),
|
||||
CHECK(level_20_max>=level_1_max)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS weapon_mod_exotic_attributes (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -1292,19 +1308,21 @@ CREATE TABLE IF NOT EXISTS gear_mod_quality_values (
|
||||
CHECK(primary_level_20_max>=primary_level_1_max)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS gear_mod_secondary_options (
|
||||
gear_mod_id TEXT NOT NULL REFERENCES gear_mods(id) ON DELETE CASCADE,
|
||||
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
|
||||
PRIMARY KEY(gear_mod_id,stat_attribute_id)
|
||||
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS gear_mod_secondary_quality_values (
|
||||
gear_mod_id TEXT NOT NULL,
|
||||
stat_attribute_id TEXT NOT NULL,
|
||||
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
|
||||
min_value REAL NOT NULL,
|
||||
max_value REAL NOT NULL,
|
||||
PRIMARY KEY(gear_mod_id,stat_attribute_id,quality),
|
||||
FOREIGN KEY(gear_mod_id,stat_attribute_id) REFERENCES gear_mod_secondary_options(gear_mod_id,stat_attribute_id) ON DELETE CASCADE,
|
||||
CHECK(max_value>=min_value)
|
||||
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
|
||||
level_1_min REAL NOT NULL,
|
||||
level_1_max REAL NOT NULL,
|
||||
level_20_min REAL NOT NULL,
|
||||
level_20_max REAL NOT NULL,
|
||||
PRIMARY KEY(stat_attribute_id,quality),
|
||||
FOREIGN KEY(stat_attribute_id) REFERENCES gear_mod_secondary_options(stat_attribute_id) ON DELETE CASCADE,
|
||||
CHECK(level_1_max>=level_1_min),
|
||||
CHECK(level_20_max>=level_20_min),
|
||||
CHECK(level_20_min>=level_1_min),
|
||||
CHECK(level_20_max>=level_1_max)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS specialization_progression_config (
|
||||
id TEXT PRIMARY KEY CHECK(id='default'),
|
||||
@@ -1628,7 +1646,7 @@ INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator
|
||||
INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator,display_order,locked_position)
|
||||
VALUES('calculation-stage-cycle-1','calculation-revision-1','firing_cycle','Firing cycle','firing_cycle',9,'last');
|
||||
CREATE TABLE IF NOT EXISTS game_data_icons (
|
||||
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node')),
|
||||
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node', 'weapon_mod_slot')),
|
||||
entity_id TEXT NOT NULL,
|
||||
storage_key TEXT NOT NULL UNIQUE,
|
||||
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
|
||||
|
||||
+110
-31
@@ -1572,13 +1572,42 @@ an icon in every option.
|
||||
|
||||
### Skill Chip data console
|
||||
|
||||
Skill Chip editing uses the dense ISAC staged-definition pattern: identity and
|
||||
icon, 2-piece numeric bonus, mutually exclusive primary and secondary roll
|
||||
pools, then 3-piece talent. Superior blue and High-end yellow identify the
|
||||
supported qualities; reserve orange for actions and the current interactive
|
||||
state. A four-step segmented preview shows the linear zero-to-three High-end
|
||||
interpolation without duplicate values. At narrow widths, stack roll-pool and
|
||||
talent columns, use a two-by-two preview grid, and retain 44-pixel targets.
|
||||
Place two shared configuration cards above the Skill Chip definition registry.
|
||||
The `SHARED PRIMARY PROFILE` card contains the fixed Skill Duration, Skill
|
||||
Radius, Skill Cooldown Recovery, and Skill Intensity rows, with direct minimum
|
||||
and maximum fields for Standard, Specialized, Superior, and High-end. Its
|
||||
summary shows the configured count for each quality and the `NOT YET DEFINED`
|
||||
calculation state. The `SHARED SECONDARY POOL` card contains repeatable
|
||||
searchable Attribute rows with direct minimum and maximum fields for
|
||||
Specialized, Superior, and High-end. Its summary shows each quality's fixed
|
||||
capacity and the shared option count. Each card opens its own editor and closes
|
||||
that editor after a successful save. Validation keeps the affected editor open
|
||||
with its values intact. Successful saves append
|
||||
`game_data/skill_chip_primary_profile.updated` or
|
||||
`game_data/skill_chip_secondary_pool.updated`.
|
||||
|
||||
State the fixed secondary capacities in the pool card and editor: Standard zero,
|
||||
Specialized one, Superior two, and High-end two. Skill Chip fields never show an
|
||||
item-level control or level-based preview. Quality labels use the global green,
|
||||
blue, purple, and yellow treatments; orange remains reserved for actions and
|
||||
selection. On narrow screens, profile summaries stack, endpoint matrices scroll
|
||||
inside their cards, and actions retain 44-pixel targets.
|
||||
|
||||
Definition editors follow the dense staged pattern: ability assignment, identity
|
||||
and icon, one per-set 2-piece bonus, then the optional 3-piece Talent. Place the
|
||||
Talent assignment control before one disclosure that contains the entire Talent
|
||||
workspace: identity, description, quality-mix preview, and
|
||||
`MechanicProgramEditor`. The disclosure summary shows the configured Talent name
|
||||
or `TALENT WORKSPACE`, the quality-scaled outcome count, and an explicit
|
||||
`EXPAND` or `COLLAPSE` action. Existing Talents start collapsed when an operator
|
||||
opens a definition. Assigning a Talent or receiving a Talent validation error
|
||||
opens the workspace. Native disclosure semantics expose the expanded state, keep
|
||||
draft state mounted while collapsed, and provide a 44-pixel mobile target.
|
||||
|
||||
Every equipped chip quality counts toward the two-piece threshold. The set-bonus
|
||||
preview uses four steps for zero through three equipped High-end chips and the
|
||||
formula `minimum + (maximum − minimum) × h / 3`; only High-end chips increase
|
||||
`h`. Do not duplicate calculated values as editable fields.
|
||||
|
||||
### ISAC record identity controls
|
||||
|
||||
@@ -1808,6 +1837,15 @@ same `weaponBaseRange` curve as the builder; never label the configured base
|
||||
endpoints as level-50 values. Weapon Talent 1 and Talent 2 pools use separate
|
||||
clickable icon rails and the standard Talent quick view.
|
||||
|
||||
Show each weapon's named Mod slots and slot pictograms between its performance
|
||||
facts and Talent pools. Native T0 weapons display the complete T1/T2 slot set.
|
||||
Mark the slot that is absent at T0 with the blue information state and an
|
||||
explicit `T1+` label so the unlock does not rely on color alone. Other slots use
|
||||
the neutral telemetry treatment. If ISAC has no slot data, show
|
||||
`MOD SLOT DATA NOT CONFIGURED`. Do not show the weapon-category bonus count as
|
||||
`BASE BONUSES`; that identical count does not help players compare weapon
|
||||
definitions.
|
||||
|
||||
Record cards may end with contextual actions that connect related public
|
||||
registries. Actions use the compact orange tactical-button treatment and encode
|
||||
their destination filter in the URL. Brand cards provide a “find Gear” action
|
||||
@@ -1970,18 +2008,26 @@ mode with weapon icons and retained accessible names. Show configured versus
|
||||
required slot counts in text. Disabled tiers remain visibly unavailable and must
|
||||
not submit slot assignments.
|
||||
|
||||
Quality-scaled registry editors use a compact matrix when the same numeric shape
|
||||
repeats across all four quality levels. Apply global quality colors only to the
|
||||
quality labels, retain textual quality names, and keep numeric fields neutral.
|
||||
On narrow screens the matrix may scroll horizontally while its surrounding
|
||||
editor and previews stack.
|
||||
Regular Weapon Mod definitions use a compact Level 1 and Level 20 primary-range
|
||||
matrix for Standard, Specialized, Superior, and High-end quality. Apply global
|
||||
quality colors only to the quality labels, retain textual quality names, and
|
||||
keep numeric fields neutral. On narrow screens the matrix may scroll
|
||||
horizontally while its surrounding editor and previews stack. Primary core type
|
||||
remains builder-selected and appears as the three always-available core icons
|
||||
rather than a persisted ISAC choice. Individual regular Weapon Mod editors also
|
||||
retain triggerless Mod Effects with one or two Attribute references and fixed
|
||||
per-quality values.
|
||||
|
||||
When two item slots draw from the same canonical option pool, edit that pool
|
||||
once rather than rendering two duplicated selectors. Weapon Mods use a
|
||||
repeatable secondary-attribute row: one searchable attribute reference plus four
|
||||
quality range pairs. Show that the builder selects two distinct options from the
|
||||
pool. Primary core type is likewise builder-selected and is displayed as the
|
||||
three always-available core icons rather than a persisted ISAC choice.
|
||||
Configure one global secondary Attribute pool above the Weapon Mod registry.
|
||||
Each repeatable row contains one searchable Attribute reference and Level 1 and
|
||||
Level 20 minimum and maximum fields for Specialized, Superior, and High-end.
|
||||
Values between those endpoints use linear interpolation. Do not render Standard
|
||||
range fields because Standard has no secondary slot. State the fixed capacity in
|
||||
the pool editor and builder: Standard zero, Specialized one, Superior two, and
|
||||
High-end two. The builder requires distinct selections. Individual Weapon Mod
|
||||
editors must not repeat or submit pool data. A successful pool save uses the
|
||||
standard shared-profile lifecycle and creates the dedicated
|
||||
`game_data/weapon_mod_secondary_pool.updated` audit event.
|
||||
|
||||
Exotic Weapon Mod status is a separate single-icon toggle using the exotic icon
|
||||
and the standard pressed, focus-visible, and selected states. Selecting it must
|
||||
@@ -1993,12 +2039,20 @@ state panel visible so the change in data semantics is unmistakable.
|
||||
|
||||
### Gear Mod registry
|
||||
|
||||
Gear Mods reuse the compact quality matrix, level preview, and pooled
|
||||
secondary-attribute rows established by Weapon Mods. Do not render exotic,
|
||||
Gear Mods reuse the compact Level 1 and Level 20 primary-range matrix and level
|
||||
preview established by regular Weapon Mods. Do not render exotic,
|
||||
weapon-category, slot-type, or Mod Effect controls in this simpler editor.
|
||||
Present the five primary options as an always-visible informational band because
|
||||
the actual primary is selected on the build instance, not persisted on the
|
||||
canonical definition.
|
||||
the build instance selects the primary; the canonical definition does not store
|
||||
that selection.
|
||||
|
||||
Configure the Gear Mod family’s global secondary Attribute pool above the
|
||||
registry, separate from individual Gear Mod editors. Use the same three-quality
|
||||
endpoint rows, linear interpolation, and distinct-selection rule as the Weapon
|
||||
Mod pool. Capacity is zero for Standard, one for Specialized, and two each for
|
||||
Superior and High-end. Individual Gear Mod edits must not repeat or submit this
|
||||
pool. A successful pool save creates the dedicated
|
||||
`game_data/gear_mod_secondary_pool.updated` audit event.
|
||||
|
||||
Use explicit textual tier capacity wherever Gear tier is summarized:
|
||||
`1 MOD SLOT` for T0 and `2 MOD SLOTS` for T1/T2. On narrow screens the identity
|
||||
@@ -2325,6 +2379,16 @@ applied to the stored value. Restoration outcomes also expose an `INSTANT` or
|
||||
field and describes the configured value as the total delivered across that
|
||||
duration; switching back to instant clears the duration.
|
||||
|
||||
Target-scoped direct-damage outcomes offer `HIT-TARGET EXPLOSION` in the Area
|
||||
Targeting control. Selecting it creates an event-target-centered enemy area with
|
||||
a five-meter starting radius, reveals the required radius field, and shows a
|
||||
text readout for the center, valid-target policy, inclusive boundary, and
|
||||
runtime evidence requirement. Only `ON EVENT` and `AFTER EVENT` remain available
|
||||
for this mode; selecting it from another phase moves the outcome to
|
||||
`AFTER EVENT`. Changing the outcome kind or target scope clears an incompatible
|
||||
area definition. Keep `ENEMIES IN RADIUS · LEGACY` visible for saved descriptive
|
||||
radius metadata, but do not present that mode as calculated explosion fan-out.
|
||||
|
||||
Action Family recipes are optional authoring starters. Present the recipe
|
||||
catalog as a searchable assignment with only the parameters required by the
|
||||
selected family. `ADD RECIPE BRANCH` copies typed branches into the current
|
||||
@@ -2511,10 +2575,11 @@ conditional Talent outcomes, weapon and Skill DPS, encounter assumptions,
|
||||
activation profiles, rotations, and simulation behind explicit `COMING SOON`
|
||||
panels.
|
||||
|
||||
Loadout schema version 4 retains `maximum`, `custom`, conditional state,
|
||||
encounter-assumption, and per-ability activation fields for compatibility. The
|
||||
current player UI ignores those fields. When player simulation ships, one
|
||||
required two-option `SegmentedChoice` will use these visible labels:
|
||||
Loadout schema version 5 retains `maximum`, `custom`, conditional state,
|
||||
encounter-assumption, and per-ability activation fields. Version 4 is obsolete
|
||||
prototype data rather than a compatibility format. The current player UI ignores
|
||||
the simulation fields. When player simulation ships, one required two-option
|
||||
`SegmentedChoice` will use these visible labels:
|
||||
|
||||
- `MAXIMUM POTENTIAL` calculates a configured ceiling. It applies conditional
|
||||
effects at peak state and treats selected abilities as firing on cooldown
|
||||
@@ -2566,7 +2631,11 @@ Focus sets the primary Attribute, progression tree, and three ordered abilities.
|
||||
Its identity combines Specialization artwork and the primary Attribute mark.
|
||||
Each ability panel contains three fixed Skill Chip positions. A chip picker
|
||||
shows definitions assigned to that ability and excludes definitions assigned
|
||||
elsewhere.
|
||||
elsewhere. 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.
|
||||
Secondary capacity is Standard zero, Specialized one, Superior two, and High-end
|
||||
two; each equipped chip uses distinct selections from the shared pool.
|
||||
|
||||
The focus tree groups nodes by row and uses segmented rank controls. Disabled
|
||||
ranks remain visible with their lock state. The builder permits a row after the
|
||||
@@ -2581,11 +2650,11 @@ enabled ability. Future Skill result cards will show activation count,
|
||||
single-target DPS, total-target DPS, and a combined summary. They will state
|
||||
that Skills can critically hit and multi-shot but cannot headshot.
|
||||
|
||||
Saved loadouts store focus, node ranks, nine Skill Chips, conditional modes,
|
||||
Version 5 loadouts store focus, node ranks, nine Skill Chips, conditional modes,
|
||||
encounter assumptions, and per-ability activation profiles. The builder accepts
|
||||
only the current complete structure for compatibility. Current player pages
|
||||
ignore the simulation fields. Invalid local drafts are discarded; invalid server
|
||||
records are removed through a backed-up database migration.
|
||||
only the current complete structure. Current player pages ignore the simulation
|
||||
fields. The builder discards version 4 local drafts; a backed-up database
|
||||
migration removes version 4 server records.
|
||||
|
||||
### Calculation revision controls
|
||||
|
||||
@@ -2650,3 +2719,13 @@ asset and states that the change is permanent. For assigned icons, the
|
||||
confirmation names the owning entity type. ISAC clears that source icon
|
||||
assignment before it removes the shared file. General library uploads retain the
|
||||
`ICON` or `IMAGE` state.
|
||||
|
||||
In the Weapon Mod registry, operators manage shared artwork for all eight
|
||||
code-defined slots. The compact panel labels each slot `ICON REGISTERED` or
|
||||
`ICON REQUIRED`. Selecting a slot opens the standard icon upload control. Keep
|
||||
the slot name visible in ISAC after an operator uploads artwork.
|
||||
|
||||
On public weapon cards, a configured slot chip shows only its uploaded icon.
|
||||
Keep the slot name in screen-reader and hover text. Retain the visible `T1+`
|
||||
marker and blue information treatment for slots unlocked at Tier 1. When artwork
|
||||
is missing, show the slot name instead of an invented glyph.
|
||||
|
||||
+139
-55
@@ -431,11 +431,13 @@ reference.
|
||||
|
||||
# OS Protocol progression
|
||||
|
||||
OS Protocols use dedicated normalized tables because their talent progression
|
||||
has six calculated states (base plus five upgrades) rather than the shared
|
||||
two-tier talent model. Each protocol owns one `os_protocol_primary_effects`
|
||||
record and one `os_protocol_talents` record. Each protocol Talent owns its
|
||||
mechanic program through the shared conditional-effect tables.
|
||||
OS Protocols use dedicated normalized tables because an assigned Talent has six
|
||||
calculated states: its base value plus five upgrades. Each protocol owns one
|
||||
`os_protocol_primary_effects` record and may own one `os_protocol_talents`
|
||||
record. The optional Talent owns its mechanic program through the shared
|
||||
conditional-effect tables. ISAC keeps the Talent workspace collapsed until an
|
||||
operator assigns or opens it, and removing an existing Talent requires explicit
|
||||
confirmation.
|
||||
|
||||
Dedicated profile tables normalize the three secondary attributes. One
|
||||
`os_protocol_secondary_profiles` record exists for each configured quality +
|
||||
@@ -460,6 +462,15 @@ compatible base weapon categories through `weapon_mod_compatible_categories`;
|
||||
eligibility requires both a category match and the slot to exist on the equipped
|
||||
weapon at its selected tier.
|
||||
|
||||
The eight code-defined slot types have stable UUIDs and one shared icon each.
|
||||
Operators manage the artwork in the Weapon Mod registry through the game-data
|
||||
icon pipeline. Each upload or removal appends a
|
||||
`game_data/weapon_mod_slot_icon.updated` audit event, and contributors may send
|
||||
the same action to Data Oversight. Public weapon cards use icon-only slot chips
|
||||
when artwork exists, with the slot name available to screen readers and on
|
||||
hover. Cards show the name when artwork is missing and retain the visible `T1+`
|
||||
marker for slots unlocked at Tier 1.
|
||||
|
||||
Exotic Weapon Mods instead reference one `exotic_weapon_id`. They do not carry
|
||||
regular category compatibility. The builder treats them as permanent equipment
|
||||
and lets the user change only their level. They do not use quality profiles,
|
||||
@@ -469,16 +480,21 @@ values in `weapon_mod_exotic_attributes`; intermediate values use linear
|
||||
interpolation.
|
||||
|
||||
All regular Weapon Mods permit Firepower, Toughness, or Engineering as a
|
||||
builder-time primary choice. `weapon_mod_quality_values` stores the shared
|
||||
level-1 and level-20 primary roll endpoints for each quality; primary values use
|
||||
linear interpolation from levels 1–20.
|
||||
builder-time primary choice. `weapon_mod_quality_values` stores each
|
||||
definition's level-1 and level-20 primary roll endpoints for each quality;
|
||||
primary values use linear interpolation from levels 1–20.
|
||||
|
||||
`weapon_mod_secondary_options` is the shared pool from which a build selects two
|
||||
distinct secondary attributes. Every option owns a minimum and maximum at each
|
||||
quality through `weapon_mod_secondary_quality_values`; secondary availability is
|
||||
not tied to a numbered slot in canonical data. Mod Effects are triggerless, hold
|
||||
one or two attribute references, and store fixed per-quality values in
|
||||
`weapon_mod_effect_quality_values`.
|
||||
`weapon_mod_secondary_options` stores one global secondary Attribute pool for
|
||||
all regular Weapon Mods. Each Attribute has Level 1 and Level 20 minimum and
|
||||
maximum ranges for Specialized, Superior, and High-end quality through
|
||||
`weapon_mod_secondary_quality_values`; values at intermediate levels use linear
|
||||
interpolation. Secondary capacity is fixed by quality: Standard has zero,
|
||||
Specialized has one, Superior has two, and High-end has two. A build selects
|
||||
distinct Attributes from the pool up to that capacity. Individual Weapon Mod
|
||||
records do not own secondary options or ranges. Pool updates append a
|
||||
`game_data/weapon_mod_secondary_pool.updated` audit event. Mod Effects remain
|
||||
triggerless, hold one or two Attribute references, and store fixed per-quality
|
||||
values in `weapon_mod_effect_quality_values`.
|
||||
|
||||
### Gear Mods and tier slot capacity
|
||||
|
||||
@@ -488,42 +504,68 @@ Specialized, Superior, and High-end quality at Levels 1 and 20. Values between
|
||||
those endpoints use linear interpolation. A build instance selects one primary
|
||||
attribute from Firepower, Toughness, Engineering, Health, or Armor.
|
||||
|
||||
The two secondary slots draw distinct attributes from one shared option pool.
|
||||
Each available secondary attribute stores its own minimum and maximum for all
|
||||
four qualities. Gear tier determines capacity without per-item configuration: T0
|
||||
gear has one Gear Mod slot; T1 and T2 gear have two.
|
||||
One global Gear Mod secondary Attribute pool serves every Gear Mod definition.
|
||||
Each Attribute has Level 1 and Level 20 minimum and maximum ranges for
|
||||
Specialized, Superior, and High-end quality through
|
||||
`gear_mod_secondary_quality_values`; values at intermediate levels use linear
|
||||
interpolation. Secondary capacity is fixed by quality: Standard has zero,
|
||||
Specialized has one, Superior has two, and High-end has two. A build selects
|
||||
distinct Attributes from the pool up to that capacity. Individual Gear Mod
|
||||
records do not own secondary options or ranges. Gear tier still determines mod
|
||||
slot capacity without per-item configuration: T0 gear has one Gear Mod slot; T1
|
||||
and T2 gear have two.
|
||||
|
||||
The `gear_mods`, `gear_mod_quality_values`, `gear_mod_secondary_options`, and
|
||||
`gear_mod_secondary_quality_values` tables store canonical data. Successful
|
||||
mutations use `gear_mod.created`, `gear_mod.updated`, `gear_mod.archived`, and
|
||||
`gear_mod.unarchived` audit actions. Contributor submissions use the `gearmods`
|
||||
proposal module and remain non-canonical until approved by Oversight.
|
||||
The `gear_mods` and `gear_mod_quality_values` tables store individual definition
|
||||
data. `gear_mod_secondary_options` and `gear_mod_secondary_quality_values` store
|
||||
the family-wide pool. Pool updates append a
|
||||
`game_data/gear_mod_secondary_pool.updated` audit event. Individual definition
|
||||
mutations retain the `gear_mod.created`, `gear_mod.updated`,
|
||||
`gear_mod.archived`, and `gear_mod.unarchived` audit actions. Contributor
|
||||
submissions use the `gearmods` proposal module and remain non-canonical until
|
||||
approved by Oversight.
|
||||
|
||||
# Skill Chip quality scaling
|
||||
|
||||
Skill Chip sets support Superior and High-end chip instances. Each set owns one
|
||||
2-piece bonus, distinct primary and secondary stat-option pools, and one 3-piece
|
||||
Talent with a typed conditional rule. Primary and secondary options reference
|
||||
active Attributes that define Skill Chip minimum and maximum roll values. A
|
||||
configured chip rolls one primary and two distinct secondary stats from those
|
||||
pools.
|
||||
Each Skill Chip definition belongs to one specialization ability. Each set owns
|
||||
one 2-piece bonus and may own one 3-piece Talent with a typed conditional rule.
|
||||
ISAC keeps the complete Talent workspace, including its description and mechanic
|
||||
program, inside one collapsible optional section.
|
||||
|
||||
Each active Skill Chip definition belongs to one active specialization ability
|
||||
through `skill_chip_sets.specialization_ability_id`. An ability has capacity for
|
||||
three active definitions. The save and restore actions enforce that capacity,
|
||||
and the schema requires the foreign key for each row. A specialization focus is
|
||||
All definitions use one fixed primary profile. The profile contains Skill
|
||||
Duration, Skill Radius, Skill Cooldown Recovery, and Skill Intensity, with
|
||||
direct minimum and maximum values for Standard, Specialized, Superior, and
|
||||
High-end. Primary values apply to the assigned ability rather than the Agent.
|
||||
Builder and shared Build pages label them `NOT YET DEFINED` and exclude them
|
||||
from current Agent and combat totals.
|
||||
|
||||
All definitions also use one shared secondary Attribute pool. Each pool entry
|
||||
stores direct minimum and maximum values for Specialized, Superior, and
|
||||
High-end. Standard chips have no secondary slot, Specialized chips have one, and
|
||||
Superior and High-end chips have two. A configured chip selects distinct
|
||||
secondary Attributes up to its quality's capacity. Skill Chips have no item
|
||||
level, so neither profile uses level interpolation.
|
||||
|
||||
Active definitions reference an active specialization ability through
|
||||
`skill_chip_sets.specialization_ability_id`. An ability has capacity for three
|
||||
active definitions. The save and restore actions enforce that capacity, and the
|
||||
schema requires the foreign key for each row. A specialization focus is
|
||||
builder-ready only when each of its three assigned abilities has all three
|
||||
active Skill Chip definitions. Skill Chip mutations refresh readiness for
|
||||
focuses that reference both the previous and resulting ability.
|
||||
|
||||
Both set-bonus progression systems use the number of equipped High-end chips as
|
||||
the interpolation step. For `h` High-end chips, where `h` is zero through three,
|
||||
a configured minimum and maximum resolve as
|
||||
`minimum + (maximum − minimum) × h / 3`. Zero High-end chips therefore produce
|
||||
the minimum, while three produce the maximum. The editor stores endpoints and
|
||||
previews all four states, preventing duplicated tier data from diverging. The
|
||||
`game_data` audit stream captures Skill Chip mutations, owned relationships, and
|
||||
safe icon metadata.
|
||||
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
|
||||
|
||||
@@ -577,7 +619,23 @@ state, source, movement, and nearby counts travel as typed event values. The
|
||||
event registry publishes authoritative field metadata where branches need a
|
||||
bounded contract. For those events, the mechanic editor derives its event-field
|
||||
controls from that metadata. For example, `weapon_hit.values.headshot` is a
|
||||
boolean that distinguishes headshots from body shots.
|
||||
boolean that distinguishes headshots from body shots. `enemy_killed` and
|
||||
`assist` may carry `values.assignedAbilityActive`; a Skill Chip Talent uses that
|
||||
boolean to restrict a kill or assist trigger to its assigned ability's active
|
||||
period. `skill_deployed` and `signature_ability_deployed` may carry
|
||||
`values.assignedAbilityDurationSeconds` to start assigned-ability state. The
|
||||
runtime expires that state before it evaluates another event at the same
|
||||
timestamp.
|
||||
|
||||
`weapon_hit.values.aiming` records whether the Agent was aiming down sights when
|
||||
the hit resolved. A producer omits the field when it lacks authoritative aiming
|
||||
state; the runtime does not replace missing evidence with a scenario assumption.
|
||||
Predicates compare this field explicitly with `true` or `false`; negation and
|
||||
inequality cannot turn missing evidence into a match. A weapon-hit event may
|
||||
also include an event-target-centered area sample. The bounded sample lists up
|
||||
to 256 valid ally and enemy entity IDs with their distances from the hit target
|
||||
and includes that target as an enemy at zero meters. Duplicate IDs, negative
|
||||
distances, or a missing center target invalidate the evaluation.
|
||||
|
||||
`extra_health_changed` is Agent-scoped. Normalized timeline producers emit it
|
||||
when the Agent receives an Extra Health grant, when the remaining amount
|
||||
@@ -612,7 +670,10 @@ The program graph uses three child tables:
|
||||
durations, intervals, or derived values. Mutations can set, add, consume,
|
||||
clear, derive, or transfer state. A mutation may bind a target-scoped pool to
|
||||
the current event target, event actor, or another typed entity selector. Each
|
||||
target owns an independent value and expiry timer.
|
||||
target owns an independent value and expiry timer. A clause may require active
|
||||
assigned-ability state and carry `procChancePercent`. The runtime evaluates
|
||||
that chance after predicates and cooldown checks, before reducers, mutations,
|
||||
and outcomes.
|
||||
- `conditional_effect_outcomes` applies Attribute modifiers, direct damage,
|
||||
restoration, resource costs, or markers. Each outcome names its target scope,
|
||||
value basis, optional resource or Attribute, combat timing, optional state
|
||||
@@ -621,15 +682,37 @@ The program graph uses three child tables:
|
||||
required by the owning source. The common value models are static values, live
|
||||
Attribute values, percentage-normalized Attribute values, event-field values,
|
||||
reducer output, source-defined scenario values, and OS Protocol upgrade
|
||||
scaling. Restoration outcomes may deliver their total immediately or
|
||||
scaling. An outcome may carry `probabilityPercent`; each eligible outcome
|
||||
rolls separately. Restoration outcomes may deliver their total immediately or
|
||||
continuously over an explicit duration.
|
||||
|
||||
Target-scoped direct damage may use `event_target_area` targeting for an
|
||||
explosion around its event target. This targeting selects valid enemies and
|
||||
stores a positive radius in meters. It is valid only for `on_event` or
|
||||
`after_event` outcomes. The legacy `within_radius` shape remains descriptive
|
||||
targeting metadata and does not select runtime entities.
|
||||
|
||||
The generic runtime in `src/lib/calculations/mechanic.ts` processes combat
|
||||
events in timestamp order. Predicates decide whether an event enters a clause.
|
||||
Reducers convert event streams into activations or derived values. The runtime
|
||||
applies state mutations, enforces pool bounds and expiry, emits outcomes in
|
||||
phase order, and records telemetry for each activation.
|
||||
|
||||
When `event_target_area` damage activates, the runtime requires the triggering
|
||||
event's authoritative area sample. It includes valid enemies whose distance is
|
||||
less than or equal to the configured radius, sorts them by entity ID, and emits
|
||||
one equal damage outcome for each entity. The outcome chance resolves once
|
||||
before fan-out. A missing sample invalidates the evaluation instead of producing
|
||||
single-target damage or treating the area as empty.
|
||||
|
||||
Custom evaluation uses a Mulberry32 stream seeded by `randomSeed`; an omitted
|
||||
seed resolves from the stable mechanic program ID. The returned evaluation
|
||||
reports the seed. Maximum mode resolves every nonzero clause or outcome chance
|
||||
as successful. `extend_duration` changes only an active assigned ability's
|
||||
remaining duration. `start_cooldown_on_max` writes an expiring Agent cooldown
|
||||
pool only when an earlier mutation in the same clause transitions a capped stack
|
||||
pool to its maximum.
|
||||
|
||||
Each write to an expiring pool refreshes its timer. When a pool expires,
|
||||
`on_exit` outcomes evaluate stack thresholds and per-stack expressions against
|
||||
the final value before reset. The runtime records the bound target on emitted
|
||||
@@ -793,17 +876,17 @@ applicable, default value, and best-case value. ISAC retains these definitions
|
||||
for continued mechanic authoring. Current Builder and shared Build pages do not
|
||||
render or evaluate them. Their calculation panels display `COMING SOON` instead.
|
||||
|
||||
Loadout schema version 4 stores one focus, selected tree ranks, nine Skill
|
||||
Loadout schema version 5 stores one focus, selected tree ranks, nine Skill
|
||||
Chips, a unified conditional-effect state map, encounter assumptions,
|
||||
per-ability activation controls, and `calculationMode`. Runtime readers accept
|
||||
the exact version 4 structure. The schema retains its simulation fields for
|
||||
compatibility, but the current player UI ignores them. A backed-up prototype
|
||||
migration removes drafts, saved builds, and calibration snapshots that use
|
||||
incompatible payloads.
|
||||
the exact version 5 structure. The schema retains its simulation fields, but the
|
||||
current player UI ignores them. Version 4 is obsolete prototype data. A
|
||||
backed-up prototype migration removes version 4 drafts, saved builds, and
|
||||
calibration snapshots instead of translating them.
|
||||
|
||||
## Planned player calculation modes
|
||||
|
||||
Loadout v4 defines `maximum` and `custom` modes for the planned simulation UI:
|
||||
Loadout v5 defines `maximum` and `custom` modes for the planned simulation UI:
|
||||
|
||||
- Maximum mode supplies each scenario input's best-case value, selects peak
|
||||
conditional-effect state, and runs selected abilities on cooldown against
|
||||
@@ -925,11 +1008,12 @@ does not scale.
|
||||
|
||||
# Game-data icon storage
|
||||
|
||||
Talents, brand sets, OS Protocols, and Skill Chips reference uploaded artwork
|
||||
through the polymorphic `game_data_icons` metadata table. The shared server
|
||||
pipeline accepts transparent SVG, PNG, WebP, and AVIF sources, rejects active
|
||||
SVG content and external references, and uses Sharp’s native worker pool to
|
||||
rasterize and normalize each accepted icon to a transparent 256 × 256 WebP.
|
||||
Talents, brand sets, OS Protocols, Skill Chips, Specializations, and shared
|
||||
Weapon Mod slots reference uploaded artwork through the polymorphic
|
||||
`game_data_icons` metadata table. The shared server pipeline accepts transparent
|
||||
SVG, PNG, WebP, and AVIF sources, rejects active SVG content and external
|
||||
references, and uses Sharp’s native worker pool to rasterize and normalize each
|
||||
accepted icon to a transparent 256 × 256 WebP.
|
||||
|
||||
Normalized files live beneath the ignored `data/game-icons/` directory. The
|
||||
validated `/icons/game-data/[type]/[id]` endpoint serves them with immutable
|
||||
|
||||
+327
-3
@@ -91,9 +91,14 @@ const isWeaponSelection = (value) =>
|
||||
const isChipSelection = (value) =>
|
||||
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
|
||||
typeof value.setId === 'string' &&
|
||||
(value.quality === 'Superior' || value.quality === 'High-end') &&
|
||||
['Standard', 'Specialized', 'Superior', 'High-end'].includes(value.quality) &&
|
||||
isRoll(value.primary) &&
|
||||
isRollPair(value.secondary);
|
||||
Array.isArray(value.secondary) &&
|
||||
value.secondary.length ===
|
||||
({ Standard: 0, Specialized: 1, Superior: 2, 'High-end': 2 }[
|
||||
value.quality
|
||||
] ?? -1) &&
|
||||
value.secondary.every(isRoll);
|
||||
const isProtocolSelection = (value) =>
|
||||
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) &&
|
||||
typeof value.itemId === 'string' &&
|
||||
@@ -185,7 +190,7 @@ const isCurrentLoadoutSnapshot = (raw) => {
|
||||
'skillActivations',
|
||||
'specialization',
|
||||
]) &&
|
||||
value.version === 4 &&
|
||||
value.version === 5 &&
|
||||
typeof value.buildName === 'string' &&
|
||||
['maximum', 'custom'].includes(value.calculationMode) &&
|
||||
hasExactKeys(value.gear, gearSlots) &&
|
||||
@@ -2392,6 +2397,32 @@ if (
|
||||
);
|
||||
}
|
||||
|
||||
const weaponModSlotIconDefinition = await (
|
||||
await database.prepare(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'",
|
||||
)
|
||||
).get();
|
||||
if (
|
||||
typeof weaponModSlotIconDefinition?.sql === 'string' &&
|
||||
!weaponModSlotIconDefinition.sql.includes("'weapon_mod_slot'")
|
||||
) {
|
||||
console.log('Adding shared Weapon Mod slot icon storage support.');
|
||||
await database.exec(`CREATE TABLE game_data_icons_weapon_mod_slots (
|
||||
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node', 'weapon_mod_slot')),
|
||||
entity_id TEXT NOT NULL,
|
||||
storage_key TEXT NOT NULL UNIQUE,
|
||||
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(entity_type, entity_id)
|
||||
)`);
|
||||
await database.exec(`INSERT INTO game_data_icons_weapon_mod_slots
|
||||
SELECT entity_type,entity_id,storage_key,byte_size,updated_at FROM game_data_icons`);
|
||||
await database.exec('DROP TABLE game_data_icons');
|
||||
await database.exec(
|
||||
'ALTER TABLE game_data_icons_weapon_mod_slots RENAME TO game_data_icons',
|
||||
);
|
||||
}
|
||||
|
||||
const managedImageColumns = await (
|
||||
await database.prepare("PRAGMA table_info('managed_images')")
|
||||
).all();
|
||||
@@ -2433,6 +2464,7 @@ const iconLibraryCategory = {
|
||||
specialization: 'Specializations',
|
||||
specialization_ability: 'Specializations',
|
||||
specialization_node: 'Specializations',
|
||||
weapon_mod_slot: 'Weapon Mods',
|
||||
};
|
||||
const iconLibraryLabel = {
|
||||
talent: 'Talent',
|
||||
@@ -2442,6 +2474,17 @@ const iconLibraryLabel = {
|
||||
specialization: 'Specialization',
|
||||
specialization_ability: 'Specialization Ability',
|
||||
specialization_node: 'Specialization Tree Node',
|
||||
weapon_mod_slot: 'Weapon Mod Slot',
|
||||
};
|
||||
const weaponModSlotNames = {
|
||||
'28ffc3b1-5d98-4273-a9af-67dbff3e5c76': 'Optics',
|
||||
'c4598f51-2cb0-4a20-a3a7-6d8983a26598': 'Muzzle',
|
||||
'85ca3493-1871-4d22-950a-ed718fd547e0': 'Underbarrel',
|
||||
'4d809a35-8285-4bff-be98-a0effe1d257e': 'Magazine',
|
||||
'0e157cfe-975c-441d-8f49-72983161e7b1': 'Barrel',
|
||||
'551fd7c8-3e5b-4c89-8437-9b901e89e8aa': 'Upper Receiver',
|
||||
'72573367-6e91-4312-9cc3-710ede4f078e': 'Lower Receiver',
|
||||
'8dda5edb-6079-4a23-a556-cd9be4521c70': 'Trigger',
|
||||
};
|
||||
const normalizeLibraryTags = (values) =>
|
||||
[
|
||||
@@ -2532,6 +2575,13 @@ const linkedIconMetadata = async (entityType, entityId) => {
|
||||
],
|
||||
};
|
||||
}
|
||||
case 'weapon_mod_slot': {
|
||||
const name = weaponModSlotNames[entityId];
|
||||
return {
|
||||
name: name ? `${name} Slot` : null,
|
||||
tags: ['weapon mod', 'slot', name],
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { name: null, tags: [] };
|
||||
}
|
||||
@@ -2912,6 +2962,267 @@ if (
|
||||
await database.exec('PRAGMA foreign_keys=ON');
|
||||
}
|
||||
|
||||
for (const kind of ['weapon', 'gear']) {
|
||||
const optionTable = `${kind}_mod_secondary_options`;
|
||||
const valueTable = `${kind}_mod_secondary_quality_values`;
|
||||
const optionColumns = await (
|
||||
await database.prepare(`PRAGMA table_info('${optionTable}')`)
|
||||
).all();
|
||||
if (!optionColumns.some((column) => column.name === `${kind}_mod_id`))
|
||||
continue;
|
||||
|
||||
const conflictingRanges = await (
|
||||
await database.prepare(`SELECT stat_attribute_id,quality,
|
||||
COUNT(DISTINCT CAST(min_value AS TEXT)||'/'||CAST(max_value AS TEXT)) AS variants
|
||||
FROM ${valueTable}
|
||||
WHERE quality IN ('Specialized','Superior','High-end')
|
||||
GROUP BY stat_attribute_id,quality HAVING variants>1`)
|
||||
).all();
|
||||
if (conflictingRanges.length) {
|
||||
throw new Error(
|
||||
`Cannot create the shared ${kind} Mod secondary pool because existing per-Mod ranges disagree. Reconcile ${conflictingRanges.map((row) => `${row.stat_attribute_id}/${row.quality}`).join(', ')} first.`,
|
||||
);
|
||||
}
|
||||
|
||||
const incompleteOptions = await (
|
||||
await database.prepare(`SELECT options.stat_attribute_id,
|
||||
COUNT(DISTINCT CASE WHEN values_row.quality IN ('Specialized','Superior','High-end') THEN values_row.quality END) AS qualities
|
||||
FROM ${optionTable} options
|
||||
LEFT JOIN ${valueTable} values_row ON values_row.${kind}_mod_id=options.${kind}_mod_id
|
||||
AND values_row.stat_attribute_id=options.stat_attribute_id
|
||||
GROUP BY options.${kind}_mod_id,options.stat_attribute_id HAVING qualities<>3`)
|
||||
).all();
|
||||
if (incompleteOptions.length) {
|
||||
throw new Error(
|
||||
`Cannot create the shared ${kind} Mod secondary pool because ${incompleteOptions.map((row) => row.stat_attribute_id).join(', ')} does not have all applicable quality ranges.`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Converting ${kind} Mod secondary Attributes to one shared level-scaled pool.`,
|
||||
);
|
||||
await database.exec('PRAGMA foreign_keys=OFF');
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
await database.exec(`CREATE TABLE ${optionTable}_shared (
|
||||
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
|
||||
)`);
|
||||
await database.exec(`CREATE TABLE ${valueTable}_shared (
|
||||
stat_attribute_id TEXT NOT NULL,
|
||||
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
|
||||
level_1_min REAL NOT NULL,
|
||||
level_1_max REAL NOT NULL,
|
||||
level_20_min REAL NOT NULL,
|
||||
level_20_max REAL NOT NULL,
|
||||
PRIMARY KEY(stat_attribute_id,quality),
|
||||
FOREIGN KEY(stat_attribute_id) REFERENCES ${optionTable}_shared(stat_attribute_id) ON DELETE CASCADE,
|
||||
CHECK(level_1_max>=level_1_min),
|
||||
CHECK(level_20_max>=level_20_min),
|
||||
CHECK(level_20_min>=level_1_min),
|
||||
CHECK(level_20_max>=level_1_max)
|
||||
)`);
|
||||
await database.exec(`INSERT INTO ${optionTable}_shared(stat_attribute_id)
|
||||
SELECT DISTINCT stat_attribute_id FROM ${optionTable}`);
|
||||
await database.exec(`INSERT INTO ${valueTable}_shared(
|
||||
stat_attribute_id,quality,level_1_min,level_1_max,level_20_min,level_20_max
|
||||
) SELECT stat_attribute_id,quality,MIN(min_value),MAX(max_value),MIN(min_value),MAX(max_value)
|
||||
FROM ${valueTable}
|
||||
WHERE quality IN ('Specialized','Superior','High-end')
|
||||
GROUP BY stat_attribute_id,quality`);
|
||||
await database.exec(`DROP TABLE ${valueTable}`);
|
||||
await database.exec(`DROP TABLE ${optionTable}`);
|
||||
await database.exec(
|
||||
`ALTER TABLE ${optionTable}_shared RENAME TO ${optionTable}`,
|
||||
);
|
||||
await database.exec(
|
||||
`ALTER TABLE ${valueTable}_shared RENAME TO ${valueTable}`,
|
||||
);
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
await database.exec('PRAGMA foreign_keys=ON');
|
||||
}
|
||||
}
|
||||
|
||||
const skillChipPrimaryOptionColumns = await (
|
||||
await database.prepare("PRAGMA table_info('skill_chip_primary_options')")
|
||||
).all();
|
||||
if (
|
||||
skillChipPrimaryOptionColumns.some(
|
||||
(column) => column.name === 'skill_chip_set_id',
|
||||
)
|
||||
) {
|
||||
const fixedSkillChipAttributes = await (
|
||||
await database.prepare(`SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active
|
||||
FROM stat_attributes
|
||||
WHERE name IN ('Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity')`)
|
||||
).get();
|
||||
if (
|
||||
Number(fixedSkillChipAttributes?.total) !== 4 ||
|
||||
Number(fixedSkillChipAttributes?.active) !== 4
|
||||
)
|
||||
throw new Error(
|
||||
'Skill Chip profile migration requires exactly four active fixed primary Attributes.',
|
||||
);
|
||||
console.log(
|
||||
'Converting Skill Chip primary and secondary options to shared quality profiles.',
|
||||
);
|
||||
await database.exec('PRAGMA foreign_keys=OFF');
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
await database.exec(
|
||||
'DROP TABLE IF EXISTS skill_chip_primary_quality_values',
|
||||
);
|
||||
await database.exec(
|
||||
'DROP TABLE IF EXISTS skill_chip_secondary_quality_values',
|
||||
);
|
||||
await database.exec(`CREATE TABLE skill_chip_primary_options_shared (
|
||||
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
|
||||
)`);
|
||||
await database.exec(`CREATE TABLE skill_chip_primary_quality_values_shared (
|
||||
stat_attribute_id TEXT NOT NULL,
|
||||
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
|
||||
min_value REAL NOT NULL,
|
||||
max_value REAL NOT NULL,
|
||||
PRIMARY KEY(stat_attribute_id,quality),
|
||||
FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_primary_options_shared(stat_attribute_id) ON DELETE CASCADE,
|
||||
CHECK(max_value>=min_value),
|
||||
CHECK(min_value>=0)
|
||||
)`);
|
||||
await database.exec(`CREATE TABLE skill_chip_secondary_options_shared (
|
||||
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
|
||||
)`);
|
||||
await database.exec(`CREATE TABLE skill_chip_secondary_quality_values_shared (
|
||||
stat_attribute_id TEXT NOT NULL,
|
||||
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
|
||||
min_value REAL NOT NULL,
|
||||
max_value REAL NOT NULL,
|
||||
PRIMARY KEY(stat_attribute_id,quality),
|
||||
FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_secondary_options_shared(stat_attribute_id) ON DELETE CASCADE,
|
||||
CHECK(max_value>=min_value),
|
||||
CHECK(min_value>=0)
|
||||
)`);
|
||||
await database.exec(`INSERT INTO skill_chip_primary_options_shared(stat_attribute_id)
|
||||
SELECT id FROM stat_attributes
|
||||
WHERE name IN ('Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity')
|
||||
AND active=1`);
|
||||
await database.exec(`INSERT INTO skill_chip_primary_quality_values_shared(
|
||||
stat_attribute_id,quality,min_value,max_value
|
||||
) SELECT attributes.id,qualities.quality,attributes.skill_chip_min,attributes.skill_chip_max
|
||||
FROM stat_attributes attributes
|
||||
CROSS JOIN (
|
||||
SELECT 'Standard' quality UNION ALL SELECT 'Specialized'
|
||||
UNION ALL SELECT 'Superior' UNION ALL SELECT 'High-end'
|
||||
) qualities
|
||||
WHERE attributes.name IN ('Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity')
|
||||
AND attributes.active=1
|
||||
AND attributes.skill_chip_min IS NOT NULL
|
||||
AND attributes.skill_chip_max IS NOT NULL`);
|
||||
await database.exec(`INSERT INTO skill_chip_secondary_options_shared(stat_attribute_id)
|
||||
SELECT DISTINCT stat_attribute_id FROM skill_chip_secondary_options`);
|
||||
await database.exec(`INSERT INTO skill_chip_secondary_quality_values_shared(
|
||||
stat_attribute_id,quality,min_value,max_value
|
||||
) SELECT options.stat_attribute_id,qualities.quality,attributes.skill_chip_min,attributes.skill_chip_max
|
||||
FROM (SELECT DISTINCT stat_attribute_id FROM skill_chip_secondary_options) options
|
||||
JOIN stat_attributes attributes ON attributes.id=options.stat_attribute_id
|
||||
CROSS JOIN (
|
||||
SELECT 'Specialized' quality UNION ALL SELECT 'Superior' UNION ALL SELECT 'High-end'
|
||||
) qualities
|
||||
WHERE attributes.skill_chip_min IS NOT NULL
|
||||
AND attributes.skill_chip_max IS NOT NULL`);
|
||||
await database.exec('DROP TABLE skill_chip_primary_options');
|
||||
await database.exec('DROP TABLE skill_chip_secondary_options');
|
||||
await database.exec(
|
||||
'ALTER TABLE skill_chip_primary_options_shared RENAME TO skill_chip_primary_options',
|
||||
);
|
||||
await database.exec(
|
||||
'ALTER TABLE skill_chip_primary_quality_values_shared RENAME TO skill_chip_primary_quality_values',
|
||||
);
|
||||
await database.exec(
|
||||
'ALTER TABLE skill_chip_secondary_options_shared RENAME TO skill_chip_secondary_options',
|
||||
);
|
||||
await database.exec(
|
||||
'ALTER TABLE skill_chip_secondary_quality_values_shared RENAME TO skill_chip_secondary_quality_values',
|
||||
);
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
await database.exec('PRAGMA foreign_keys=ON');
|
||||
}
|
||||
}
|
||||
await database.exec(`INSERT OR IGNORE INTO skill_chip_primary_options(stat_attribute_id)
|
||||
SELECT id FROM stat_attributes
|
||||
WHERE active=1 AND name IN (
|
||||
'Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity'
|
||||
)`);
|
||||
|
||||
const skillChipRangeAttributeColumns = await (
|
||||
await database.prepare("PRAGMA table_info('stat_attributes')")
|
||||
).all();
|
||||
if (
|
||||
skillChipRangeAttributeColumns.some(
|
||||
(column) => column.name === 'skill_chip_min',
|
||||
)
|
||||
) {
|
||||
console.log(
|
||||
'Removing obsolete Attribute-level Skill Chip ranges in favor of shared quality profiles.',
|
||||
);
|
||||
await database.exec('PRAGMA foreign_keys=OFF');
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
await database.exec(
|
||||
'DROP INDEX IF EXISTS stat_attributes_name_unit_nocase_idx',
|
||||
);
|
||||
await database.exec(`CREATE TABLE stat_attributes_current (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
type TEXT NOT NULL CHECK(type IN ('Offensive','Defensive')),
|
||||
category TEXT NOT NULL CHECK(category IN ('Weapon','Skill','Character')),
|
||||
calculation_type TEXT NOT NULL CHECK(calculation_type IN ('Additive','Multiplicative')),
|
||||
unit TEXT NOT NULL DEFAULT '',
|
||||
gear_min REAL,
|
||||
gear_max REAL,
|
||||
gear_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(gear_reference_level BETWEEN 1 AND 50),
|
||||
weapon_min REAL,
|
||||
weapon_max REAL,
|
||||
weapon_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50),
|
||||
cap_value REAL,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK((gear_min IS NULL AND gear_max IS NULL) OR (gear_min IS NOT NULL AND gear_max IS NOT NULL AND gear_min<=gear_max)),
|
||||
CHECK((weapon_min IS NULL AND weapon_max IS NULL) OR (weapon_min IS NOT NULL AND weapon_max IS NOT NULL AND weapon_min<=weapon_max)),
|
||||
CHECK(cap_value IS NULL OR cap_value>=0)
|
||||
)`);
|
||||
await database.exec(`INSERT INTO stat_attributes_current(
|
||||
id,name,description,type,category,calculation_type,unit,
|
||||
gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level,
|
||||
cap_value,active,created_at,updated_at
|
||||
) SELECT id,name,description,type,category,calculation_type,unit,
|
||||
gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level,
|
||||
cap_value,active,created_at,updated_at FROM stat_attributes`);
|
||||
await database.exec('DROP TABLE stat_attributes');
|
||||
await database.exec(
|
||||
'ALTER TABLE stat_attributes_current RENAME TO stat_attributes',
|
||||
);
|
||||
await database.exec(
|
||||
'CREATE UNIQUE INDEX stat_attributes_name_unit_nocase_idx ON stat_attributes(name COLLATE NOCASE,unit)',
|
||||
);
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
await database.exec('PRAGMA foreign_keys=ON');
|
||||
}
|
||||
}
|
||||
|
||||
const buildColumns = await (
|
||||
await database.prepare("PRAGMA table_info('builds')")
|
||||
).all();
|
||||
@@ -3305,6 +3616,19 @@ if (
|
||||
);
|
||||
}
|
||||
|
||||
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))',
|
||||
);
|
||||
}
|
||||
|
||||
const calibrationCases = await (
|
||||
await database.prepare(
|
||||
'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases',
|
||||
|
||||
+72
-2
@@ -44,21 +44,91 @@ for (const table of backup.tables) {
|
||||
}
|
||||
|
||||
let restoredRows = 0;
|
||||
const attributeTable = backup.tables.find(
|
||||
(table) => table.name === 'stat_attributes',
|
||||
);
|
||||
const legacySkillChipProfiles = attributeTable?.rows.some(
|
||||
(row) => 'skill_chip_min' in row || 'skill_chip_max' in row,
|
||||
);
|
||||
const legacySkillChipRanges = new Map(
|
||||
(attributeTable?.rows ?? []).map((row) => [
|
||||
String(row.id),
|
||||
{ min: row.skill_chip_min, max: row.skill_chip_max },
|
||||
]),
|
||||
);
|
||||
const restoredLegacySecondaries = new Set();
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
for (const table of backup.tables) {
|
||||
for (const row of table.rows) {
|
||||
const columns = Object.keys(row);
|
||||
if (
|
||||
legacySkillChipProfiles &&
|
||||
table.name === 'skill_chip_primary_options'
|
||||
)
|
||||
continue;
|
||||
const restoredRow = { ...row };
|
||||
if (legacySkillChipProfiles && table.name === 'stat_attributes') {
|
||||
delete restoredRow.skill_chip_min;
|
||||
delete restoredRow.skill_chip_max;
|
||||
}
|
||||
if (
|
||||
legacySkillChipProfiles &&
|
||||
table.name === 'skill_chip_secondary_options'
|
||||
) {
|
||||
delete restoredRow.skill_chip_set_id;
|
||||
const statId = String(restoredRow.stat_attribute_id ?? '');
|
||||
if (restoredLegacySecondaries.has(statId)) continue;
|
||||
restoredLegacySecondaries.add(statId);
|
||||
}
|
||||
const columns = Object.keys(restoredRow);
|
||||
if (!columns.length) continue;
|
||||
const columnSql = columns.map(quoteIdentifier).join(', ');
|
||||
const placeholders = columns.map(() => '?').join(', ');
|
||||
const insert = await database.prepare(
|
||||
`INSERT INTO ${quoteIdentifier(table.name)} (${columnSql}) VALUES (${placeholders})`,
|
||||
);
|
||||
await insert.run(...columns.map((column) => row[column]));
|
||||
await insert.run(...columns.map((column) => restoredRow[column]));
|
||||
restoredRows++;
|
||||
}
|
||||
}
|
||||
if (legacySkillChipProfiles) {
|
||||
const primaryAttributes = await (
|
||||
await database.prepare(`SELECT id,active FROM stat_attributes WHERE name IN (
|
||||
'Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity'
|
||||
)`)
|
||||
).all();
|
||||
if (
|
||||
primaryAttributes.length !== 4 ||
|
||||
primaryAttributes.some((attribute) => Number(attribute.active) !== 1)
|
||||
)
|
||||
throw new Error(
|
||||
'Legacy restore requires exactly four active fixed Skill Chip primary Attributes.',
|
||||
);
|
||||
for (const attribute of primaryAttributes) {
|
||||
const statId = String(attribute.id);
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO skill_chip_primary_options(stat_attribute_id) VALUES(?)',
|
||||
)
|
||||
).run(statId);
|
||||
const range = legacySkillChipRanges.get(statId);
|
||||
if (!range || range.min === null || range.max === null) continue;
|
||||
for (const quality of ['Standard', 'Specialized', 'Superior', 'High-end'])
|
||||
await (
|
||||
await database.prepare(`INSERT INTO skill_chip_primary_quality_values
|
||||
(stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?)`)
|
||||
).run(statId, quality, range.min, range.max);
|
||||
}
|
||||
for (const statId of restoredLegacySecondaries) {
|
||||
const range = legacySkillChipRanges.get(statId);
|
||||
if (!range || range.min === null || range.max === null) continue;
|
||||
for (const quality of ['Specialized', 'Superior', 'High-end'])
|
||||
await (
|
||||
await database.prepare(`INSERT INTO skill_chip_secondary_quality_values
|
||||
(stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?)`)
|
||||
).run(statId, quality, range.min, range.max);
|
||||
}
|
||||
}
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
|
||||
@@ -98,8 +98,6 @@ try {
|
||||
weapon_min: record.weaponRange?.[0] ?? existing?.weapon_min ?? null,
|
||||
weapon_max: record.weaponRange?.[1] ?? existing?.weapon_max ?? null,
|
||||
weapon_reference_level: Number(existing?.weapon_reference_level ?? 50),
|
||||
skill_chip_min: existing?.skill_chip_min ?? null,
|
||||
skill_chip_max: existing?.skill_chip_max ?? null,
|
||||
cap_value: existing?.cap_value ?? null,
|
||||
active: 1,
|
||||
created_at: String(existing?.created_at ?? now),
|
||||
@@ -117,7 +115,7 @@ try {
|
||||
await (
|
||||
await database.prepare(`UPDATE stat_attributes SET
|
||||
name=?,description=?,type=?,category=?,calculation_type=?,unit=?,
|
||||
gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,
|
||||
gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,
|
||||
cap_value=?,active=1,updated_at=? WHERE id=?`)
|
||||
).run(
|
||||
next.name,
|
||||
@@ -130,8 +128,6 @@ try {
|
||||
next.gear_max,
|
||||
next.weapon_min,
|
||||
next.weapon_max,
|
||||
next.skill_chip_min,
|
||||
next.skill_chip_max,
|
||||
next.cap_value,
|
||||
now,
|
||||
id,
|
||||
@@ -142,8 +138,8 @@ try {
|
||||
await database.prepare(`INSERT INTO stat_attributes(
|
||||
id,name,description,type,category,calculation_type,unit,
|
||||
gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level,
|
||||
skill_chip_min,skill_chip_max,cap_value,active,created_at,updated_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`)
|
||||
cap_value,active,created_at,updated_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`)
|
||||
).run(
|
||||
id,
|
||||
next.name,
|
||||
@@ -158,8 +154,6 @@ try {
|
||||
next.weapon_min,
|
||||
next.weapon_max,
|
||||
next.weapon_reference_level,
|
||||
next.skill_chip_min,
|
||||
next.skill_chip_max,
|
||||
next.cap_value,
|
||||
now,
|
||||
now,
|
||||
|
||||
@@ -67,7 +67,7 @@ for (const row of [
|
||||
|
||||
const snapshot = {
|
||||
format: 'shd-build-isac-data',
|
||||
version: 2,
|
||||
version: 4,
|
||||
createdAt: new Date().toISOString(),
|
||||
sourceDatabase: basename(databasePath),
|
||||
totalRows,
|
||||
|
||||
@@ -22,7 +22,7 @@ const snapshotPath = resolve(snapshotArgument);
|
||||
const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8'));
|
||||
if (
|
||||
snapshot?.format !== 'shd-build-isac-data' ||
|
||||
snapshot?.version !== 2 ||
|
||||
snapshot?.version !== 4 ||
|
||||
!Array.isArray(snapshot.tables) ||
|
||||
!Array.isArray(snapshot.icons)
|
||||
) {
|
||||
@@ -39,6 +39,152 @@ if (
|
||||
'The ISAC snapshot table manifest is incomplete or out of order.',
|
||||
);
|
||||
}
|
||||
for (const kind of ['weapon', 'gear']) {
|
||||
const options = snapshot.tables.find(
|
||||
(table) => table.name === `${kind}_mod_secondary_options`,
|
||||
).rows;
|
||||
const values = snapshot.tables.find(
|
||||
(table) => table.name === `${kind}_mod_secondary_quality_values`,
|
||||
).rows;
|
||||
const optionIds = options.map((row) => String(row.stat_attribute_id ?? ''));
|
||||
if (
|
||||
optionIds.some((id) => !id) ||
|
||||
new Set(optionIds).size !== optionIds.length ||
|
||||
(optionIds.length !== 0 &&
|
||||
(optionIds.length < 2 || optionIds.length > 50)) ||
|
||||
values.length !== optionIds.length * 3
|
||||
) {
|
||||
throw new Error(
|
||||
`The ISAC snapshot contains an incomplete shared ${kind} Mod secondary pool.`,
|
||||
);
|
||||
}
|
||||
for (const statId of optionIds) {
|
||||
const profile = values.filter(
|
||||
(row) => String(row.stat_attribute_id) === statId,
|
||||
);
|
||||
if (
|
||||
profile.length !== 3 ||
|
||||
['Specialized', 'Superior', 'High-end'].some(
|
||||
(quality) => !profile.some((row) => row.quality === quality),
|
||||
) ||
|
||||
profile.some((row) => {
|
||||
const endpoints = [
|
||||
row.level_1_min,
|
||||
row.level_1_max,
|
||||
row.level_20_min,
|
||||
row.level_20_max,
|
||||
];
|
||||
return (
|
||||
endpoints.some(
|
||||
(value) => typeof value !== 'number' || !Number.isFinite(value),
|
||||
) ||
|
||||
endpoints[1] < endpoints[0] ||
|
||||
endpoints[2] < endpoints[0] ||
|
||||
endpoints[3] < endpoints[1] ||
|
||||
endpoints[3] < endpoints[2]
|
||||
);
|
||||
})
|
||||
) {
|
||||
throw new Error(
|
||||
`The ISAC snapshot contains an invalid shared ${kind} Mod secondary profile for ${statId}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
values.some(
|
||||
(row) => !optionIds.includes(String(row.stat_attribute_id ?? '')),
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`The ISAC snapshot contains an orphaned shared ${kind} Mod secondary range.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const [profileName, qualities, requiredOptions] of [
|
||||
['primary', ['Standard', 'Specialized', 'Superior', 'High-end'], 4],
|
||||
['secondary', ['Specialized', 'Superior', 'High-end'], null],
|
||||
]) {
|
||||
const options = snapshot.tables.find(
|
||||
(table) => table.name === `skill_chip_${profileName}_options`,
|
||||
).rows;
|
||||
const values = snapshot.tables.find(
|
||||
(table) => table.name === `skill_chip_${profileName}_quality_values`,
|
||||
).rows;
|
||||
const optionIds = options.map((row) => String(row.stat_attribute_id ?? ''));
|
||||
const validCardinality = requiredOptions
|
||||
? optionIds.length === requiredOptions
|
||||
: optionIds.length <= 50;
|
||||
if (
|
||||
!validCardinality ||
|
||||
optionIds.some((id) => !id) ||
|
||||
new Set(optionIds).size !== optionIds.length
|
||||
) {
|
||||
throw new Error(
|
||||
`The ISAC snapshot contains an incomplete shared Skill Chip ${profileName} profile.`,
|
||||
);
|
||||
}
|
||||
if (profileName === 'primary' && optionIds.length) {
|
||||
const attributes = snapshot.tables.find(
|
||||
(table) => table.name === 'stat_attributes',
|
||||
).rows;
|
||||
const expectedIds = attributes
|
||||
.filter(
|
||||
(row) =>
|
||||
Number(row.active) === 1 &&
|
||||
[
|
||||
'Skill Duration',
|
||||
'Skill Radius',
|
||||
'Skill Cooldown Recovery',
|
||||
'Skill Intensity',
|
||||
].includes(String(row.name)),
|
||||
)
|
||||
.map((row) => String(row.id))
|
||||
.sort();
|
||||
if (
|
||||
expectedIds.length !== 4 ||
|
||||
optionIds
|
||||
.slice()
|
||||
.sort()
|
||||
.some((id, index) => id !== expectedIds[index])
|
||||
)
|
||||
throw new Error(
|
||||
'The ISAC snapshot does not use the four fixed Skill Chip primary Attributes.',
|
||||
);
|
||||
}
|
||||
for (const statId of optionIds) {
|
||||
const rows = values.filter(
|
||||
(row) => String(row.stat_attribute_id ?? '') === statId,
|
||||
);
|
||||
if (!rows.length) continue;
|
||||
if (
|
||||
rows.length !== qualities.length ||
|
||||
qualities.some(
|
||||
(quality) => !rows.some((row) => row.quality === quality),
|
||||
) ||
|
||||
rows.some(
|
||||
(row) =>
|
||||
typeof row.min_value !== 'number' ||
|
||||
!Number.isFinite(row.min_value) ||
|
||||
typeof row.max_value !== 'number' ||
|
||||
!Number.isFinite(row.max_value) ||
|
||||
row.min_value < 0 ||
|
||||
row.max_value < row.min_value,
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`The ISAC snapshot contains an invalid shared Skill Chip ${profileName} profile for ${statId}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
values.some(
|
||||
(row) => !optionIds.includes(String(row.stat_attribute_id ?? '')),
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
`The ISAC snapshot contains an orphaned shared Skill Chip ${profileName} range.`,
|
||||
);
|
||||
}
|
||||
for (const icon of snapshot.icons) {
|
||||
if (
|
||||
typeof icon.storageKey !== 'string' ||
|
||||
|
||||
@@ -32,7 +32,9 @@ export const ISAC_DATA_TABLES = [
|
||||
'skill_chip_sets',
|
||||
'skill_chip_two_piece_bonuses',
|
||||
'skill_chip_primary_options',
|
||||
'skill_chip_primary_quality_values',
|
||||
'skill_chip_secondary_options',
|
||||
'skill_chip_secondary_quality_values',
|
||||
'skill_chip_talents',
|
||||
'skill_chip_talent_effects',
|
||||
'weapon_mods',
|
||||
|
||||
@@ -23,7 +23,7 @@ const images = [];
|
||||
for (const row of rows) {
|
||||
if (
|
||||
typeof row.storage_key !== 'string' ||
|
||||
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i.test(
|
||||
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i.test(
|
||||
row.storage_key,
|
||||
)
|
||||
) {
|
||||
|
||||
@@ -14,6 +14,19 @@ if (!bundleArgument || !process.argv.includes('--confirm-merge')) {
|
||||
process.exit(2);
|
||||
}
|
||||
const updateExisting = process.argv.includes('--update-existing');
|
||||
const gameDataRenderSourceTypes = new Set([
|
||||
'weapon_render',
|
||||
'gear_render',
|
||||
'weapon_mod_render',
|
||||
'gear_mod_render',
|
||||
'talent_render',
|
||||
'brand_set_render',
|
||||
'os_protocol_render',
|
||||
'skill_chip_render',
|
||||
'specialization_render',
|
||||
'specialization_ability_render',
|
||||
'specialization_node_render',
|
||||
]);
|
||||
const bundlePath = resolve(bundleArgument);
|
||||
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
|
||||
if (
|
||||
@@ -30,21 +43,21 @@ for (const image of bundle.images) {
|
||||
typeof image?.row?.id !== 'string' ||
|
||||
!/^[0-9a-f-]{36}$/i.test(image.row.id) ||
|
||||
typeof image.row.storage_key !== 'string' ||
|
||||
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i.test(
|
||||
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i.test(
|
||||
image.row.storage_key,
|
||||
) ||
|
||||
typeof image.contentBase64 !== 'string'
|
||||
) {
|
||||
throw new Error('The bundle contains an invalid managed image.');
|
||||
}
|
||||
const isItemRender = ['weapon_render', 'gear_render'].includes(
|
||||
const isGameDataRender = gameDataRenderSourceTypes.has(
|
||||
image.row.source_entity_type,
|
||||
);
|
||||
if (
|
||||
image.row.source_entity_type &&
|
||||
(typeof image.row.source_entity_id !== 'string' ||
|
||||
!/^[0-9a-f-]{36}$/i.test(image.row.source_entity_id) ||
|
||||
(isItemRender
|
||||
(isGameDataRender
|
||||
? !image.row.storage_key.startsWith('library-')
|
||||
: !image.row.storage_key.startsWith(
|
||||
`${image.row.source_entity_type}-`,
|
||||
|
||||
+526
@@ -4646,6 +4646,62 @@ a.secondary:active {
|
||||
font-size: var(--shd-type-telemetry);
|
||||
font-weight: bold;
|
||||
}
|
||||
.selection-head > .row-action {
|
||||
min-height: 34px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
justify-self: end;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #4c3d26;
|
||||
background: #17140f;
|
||||
color: var(--shd-orange);
|
||||
font-family: var(--shd-font-sans);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.65px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.12s ease,
|
||||
background 0.12s ease,
|
||||
color 0.12s ease,
|
||||
transform 0.08s ease;
|
||||
}
|
||||
.selection-head > .row-action:hover,
|
||||
.selection-head > .row-action:focus-visible {
|
||||
border-color: var(--shd-orange);
|
||||
background: #211a0e;
|
||||
color: var(--shd-orange-hover);
|
||||
}
|
||||
.selection-head > .row-action:focus-visible {
|
||||
outline: 1px solid var(--shd-orange);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.selection-head > .row-action:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
background: #120f0b;
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
.selection-head > .row-action:disabled {
|
||||
border-color: var(--shd-border);
|
||||
background: #111415;
|
||||
color: var(--shd-text-muted);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.selection-head > .row-action {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.selection-head > .row-action {
|
||||
transition: none;
|
||||
}
|
||||
.selection-head > .row-action:active:not(:disabled) {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.button-link {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -12585,6 +12641,134 @@ a.secondary:active {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.mod-secondary-pool-registry {
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--shd-border);
|
||||
border-left: 2px solid var(--quality-specialized);
|
||||
background: #0d1011;
|
||||
}
|
||||
.mod-secondary-capacity {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
||||
margin-top: 8px;
|
||||
border: 1px solid var(--shd-border);
|
||||
}
|
||||
.mod-secondary-capacity > span {
|
||||
min-height: 58px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
align-items: center;
|
||||
gap: 1px 8px;
|
||||
padding: 8px;
|
||||
border-left: 1px solid var(--shd-border);
|
||||
background: #101314;
|
||||
}
|
||||
.mod-secondary-capacity > span:first-child {
|
||||
border-left: 0;
|
||||
}
|
||||
.mod-secondary-capacity b,
|
||||
.mod-secondary-capacity small {
|
||||
font-size: var(--shd-type-telemetry);
|
||||
letter-spacing: 0.55px;
|
||||
}
|
||||
.mod-secondary-capacity small {
|
||||
color: var(--shd-text-muted);
|
||||
}
|
||||
.mod-secondary-capacity strong {
|
||||
grid-row: 1/3;
|
||||
grid-column: 2;
|
||||
color: var(--shd-orange);
|
||||
font-size: 24px;
|
||||
}
|
||||
.mod-secondary-capacity .pool-total {
|
||||
border-left-color: #74552a;
|
||||
background: #18150d;
|
||||
}
|
||||
.mod-secondary-pool-editor {
|
||||
border-top-color: var(--quality-specialized);
|
||||
}
|
||||
.pool-editor-head {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.mod-secondary-pool-options {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
.mod-secondary-pool-option {
|
||||
display: grid;
|
||||
grid-template-columns: 30px minmax(220px, 0.9fr) repeat(
|
||||
3,
|
||||
minmax(285px, 1fr)
|
||||
) 30px;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--shd-border);
|
||||
background: #101314;
|
||||
}
|
||||
.mod-secondary-pool-option > strong {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #171b1c;
|
||||
color: var(--shd-orange);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
}
|
||||
.mod-secondary-quality-range {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
align-items: center;
|
||||
gap: 4px 6px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--shd-border);
|
||||
}
|
||||
.mod-secondary-quality-range > b {
|
||||
grid-row: 1/3;
|
||||
font-size: var(--shd-type-telemetry);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.mod-secondary-quality-range label {
|
||||
display: grid;
|
||||
grid-template-columns: 54px 1fr;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.mod-secondary-quality-range label > span {
|
||||
color: var(--shd-text-muted);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
font-weight: 900;
|
||||
}
|
||||
.mod-secondary-quality-range label > div {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 12px 1fr;
|
||||
align-items: center;
|
||||
}
|
||||
.mod-secondary-quality-range input {
|
||||
height: 30px;
|
||||
margin: 0;
|
||||
padding: 0 5px;
|
||||
}
|
||||
.mod-secondary-quality-range i {
|
||||
color: #596164;
|
||||
font-size: var(--shd-type-telemetry);
|
||||
font-style: normal;
|
||||
text-align: center;
|
||||
}
|
||||
.mod-secondary-pool-option > button {
|
||||
align-self: end;
|
||||
height: 32px;
|
||||
border: 1px solid #563734;
|
||||
background: #1d1212;
|
||||
color: var(--shd-danger);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mod-secondary-pool-option > button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.weapon-mod-quality-row,
|
||||
.weapon-mod-quality-row.heading {
|
||||
@@ -12597,8 +12781,156 @@ a.secondary:active {
|
||||
.weapon-mod-secondary-options label:nth-of-type(4) {
|
||||
grid-column: auto;
|
||||
}
|
||||
.mod-secondary-pool-options {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.skill-chip-shared-config {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.skill-chip-shared-config .mod-secondary-pool-registry {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.skill-chip-profile-options {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.skill-chip-profile-option {
|
||||
min-width: 870px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(190px, 1.2fr) repeat(4, minmax(130px, 1fr));
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--shd-border);
|
||||
background: #0e1112;
|
||||
}
|
||||
|
||||
.skill-chip-profile-option.selectable {
|
||||
grid-template-columns: 32px minmax(210px, 1.2fr) repeat(
|
||||
3,
|
||||
minmax(130px, 1fr)
|
||||
) 34px;
|
||||
}
|
||||
|
||||
.skill-chip-profile-option > div:first-child,
|
||||
.skill-chip-profile-option > label {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border: 1px solid #292f30;
|
||||
background: #121617;
|
||||
}
|
||||
|
||||
.skill-chip-profile-option > div:first-child strong,
|
||||
.skill-chip-profile-option > div:first-child small,
|
||||
.skill-chip-profile-option > label > b {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skill-chip-profile-option > div:first-child small,
|
||||
.skill-chip-profile-option > label > b {
|
||||
font-size: var(--shd-type-micro);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.skill-chip-profile-option > label > span {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.skill-chip-profile-option input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 8px 6px;
|
||||
border: 1px solid #353c3d;
|
||||
background: #090c0d;
|
||||
color: var(--shd-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.skill-chip-profile-option > strong {
|
||||
align-self: center;
|
||||
color: var(--shd-orange);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.skill-chip-profile-option > button:last-child {
|
||||
border: 1px solid #553128;
|
||||
background: #1a1110;
|
||||
color: var(--shd-danger);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.skill-chip-profile-option > button:last-child:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.skill-chip-profile-option input:focus-visible,
|
||||
.skill-chip-profile-option > button:last-child:focus-visible {
|
||||
outline: 2px solid var(--shd-orange);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.skill-chip-talent-disclosure,
|
||||
.protocol-talent-disclosure {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.skill-chip-talent-removal,
|
||||
.protocol-talent-removal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid #57332b;
|
||||
background: #1a1110;
|
||||
color: var(--shd-danger);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.skill-chip-shared-config {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.mod-secondary-pool-option {
|
||||
min-width: 1160px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.mod-secondary-capacity {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.mod-secondary-capacity > span,
|
||||
.mod-secondary-capacity > span:first-child {
|
||||
border-top: 1px solid var(--shd-border);
|
||||
border-left: 1px solid var(--shd-border);
|
||||
}
|
||||
.mod-secondary-capacity > span:nth-child(odd) {
|
||||
border-left: 0;
|
||||
}
|
||||
.mod-secondary-capacity .pool-total {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
.weapon-mod-secondary-options > div {
|
||||
grid-template-columns: 30px 1fr;
|
||||
}
|
||||
@@ -13088,6 +13420,18 @@ a.secondary:active {
|
||||
border-top: 2px solid #404748;
|
||||
background: #111415;
|
||||
}
|
||||
.database-item-card.protocol-quality-standard {
|
||||
border-top-color: var(--quality-standard);
|
||||
}
|
||||
.database-item-card.protocol-quality-specialized {
|
||||
border-top-color: var(--quality-specialized);
|
||||
}
|
||||
.database-item-card.protocol-quality-superior {
|
||||
border-top-color: var(--quality-superior);
|
||||
}
|
||||
.database-item-card.protocol-quality-highend {
|
||||
border-top-color: var(--quality-high-end);
|
||||
}
|
||||
.database-item-card > header {
|
||||
height: auto;
|
||||
min-height: 78px;
|
||||
@@ -13193,6 +13537,68 @@ a.secondary:active {
|
||||
font-size: var(--shd-type-telemetry);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.database-weapon-mod-slots {
|
||||
display: grid;
|
||||
grid-template-columns: 110px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin: 7px 12px 0;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.database-weapon-mod-slots > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.database-weapon-mod-slots > div span,
|
||||
.database-weapon-mod-slots > p {
|
||||
color: #626a6c;
|
||||
font-size: var(--shd-type-telemetry);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.65px;
|
||||
}
|
||||
.database-weapon-mod-slots ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.database-weapon-mod-slots li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 27px;
|
||||
padding: 4px 7px;
|
||||
border: 1px solid #383f40;
|
||||
background: #0d1011;
|
||||
color: #aeb4b5;
|
||||
font-size: var(--shd-type-telemetry);
|
||||
font-weight: 800;
|
||||
}
|
||||
.database-weapon-mod-slots li.has-slot-icon {
|
||||
min-width: 34px;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
}
|
||||
.database-weapon-mod-slots li.upgrade-slot {
|
||||
border-color: #315b6a;
|
||||
background: #0b171c;
|
||||
color: var(--shd-info);
|
||||
}
|
||||
.database-weapon-mod-slots li small {
|
||||
padding-left: 6px;
|
||||
border-left: 1px solid #315b6a;
|
||||
color: var(--shd-info);
|
||||
font-size: var(--shd-type-micro);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.database-weapon-mod-slots > p {
|
||||
margin: 0;
|
||||
}
|
||||
.database-item-lines {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
@@ -13357,6 +13763,123 @@ a.secondary:active {
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.7px;
|
||||
}
|
||||
.database-header-badge.exotic {
|
||||
border-color: #74552a;
|
||||
background: #1a160d;
|
||||
color: var(--quality-high-end);
|
||||
}
|
||||
.database-header-badge.highend {
|
||||
border-color: color-mix(in srgb, var(--quality-high-end) 58%, #52431a);
|
||||
background: #1c190c;
|
||||
color: var(--quality-high-end);
|
||||
}
|
||||
.database-header-badge.protocol-badge-standard {
|
||||
border-color: color-mix(in srgb, var(--quality-standard) 58%, #354142);
|
||||
background: #111718;
|
||||
color: var(--quality-standard);
|
||||
}
|
||||
.database-header-badge.protocol-badge-specialized {
|
||||
border-color: color-mix(in srgb, var(--quality-specialized) 58%, #274338);
|
||||
background: #0e1713;
|
||||
color: var(--quality-specialized);
|
||||
}
|
||||
.database-header-badge.protocol-badge-superior {
|
||||
border-color: color-mix(in srgb, var(--quality-superior) 58%, #2c3f5a);
|
||||
background: #10161d;
|
||||
color: var(--quality-superior);
|
||||
}
|
||||
.database-header-badge.protocol-badge-highend {
|
||||
border-color: color-mix(in srgb, var(--quality-high-end) 58%, #52431a);
|
||||
background: #1c190c;
|
||||
color: var(--quality-high-end);
|
||||
}
|
||||
.database-protocol-preview {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 9px 12px 0;
|
||||
}
|
||||
.database-protocol-primary {
|
||||
min-height: 66px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(130px, 0.7fr) minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 7px;
|
||||
border: 1px solid var(--shd-border);
|
||||
border-left: 3px solid var(--shd-orange);
|
||||
background: linear-gradient(90deg, #21190e, #101314);
|
||||
}
|
||||
.database-protocol-primary span {
|
||||
padding-left: 5px;
|
||||
color: #c89138;
|
||||
font-size: var(--shd-type-telemetry);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.9px;
|
||||
}
|
||||
.database-protocol-primary b {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--shd-orange);
|
||||
font-size: 20px;
|
||||
letter-spacing: 0.7px;
|
||||
}
|
||||
.database-protocol-level {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--shd-text-muted);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.7px;
|
||||
}
|
||||
.database-protocol-level > span {
|
||||
flex: none;
|
||||
color: var(--shd-text);
|
||||
}
|
||||
.database-protocol-level input {
|
||||
width: 104px;
|
||||
accent-color: var(--shd-orange);
|
||||
}
|
||||
.database-protocol-talent {
|
||||
display: grid;
|
||||
}
|
||||
.database-protocol-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.database-protocol-diamonds {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
.database-protocol-diamonds button {
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #596164;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.database-protocol-diamonds button.active,
|
||||
.database-protocol-diamonds button:hover,
|
||||
.database-protocol-diamonds button:focus-visible {
|
||||
color: var(--quality-high-end);
|
||||
}
|
||||
.database-protocol-diamonds button:focus-visible {
|
||||
outline: 2px solid var(--shd-orange);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.database-protocol-talent p {
|
||||
margin: 0;
|
||||
padding: 9px;
|
||||
border: 1px solid var(--shd-border);
|
||||
background: #101314;
|
||||
color: #a2a8aa;
|
||||
font: var(--shd-type-supporting)/1.55 var(--shd-font-editorial);
|
||||
}
|
||||
.database-fact-value {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
@@ -13691,6 +14214,9 @@ a.secondary:active {
|
||||
.database-item-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.database-weapon-mod-slots {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.database-item-card > header {
|
||||
grid-template-columns: 52px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@@ -91,6 +91,172 @@ describe('article documents', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts and canonicalizes metadata emitted by the Tiptap editor', () => {
|
||||
const parsed = parseArticleDocument({
|
||||
version: 1,
|
||||
document: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Read more',
|
||||
marks: [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: 'https://shd.build/news',
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
class: null,
|
||||
title: 'News archive',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'orderedList',
|
||||
attrs: { start: 1, type: null },
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'First item' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed?.document.document.content).toEqual([
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Read more',
|
||||
marks: [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: 'https://shd.build/news',
|
||||
title: 'News archive',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'orderedList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'First item' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(parsed?.plainText).toContain('News archive');
|
||||
});
|
||||
|
||||
it('preserves supported ordered-list numbering', () => {
|
||||
const parsed = parseArticleDocument({
|
||||
version: 1,
|
||||
document: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'orderedList',
|
||||
attrs: { start: 2, type: 'A' },
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed?.document.document.content?.[0]).toMatchObject({
|
||||
type: 'orderedList',
|
||||
attrs: { start: 2, type: 'A' },
|
||||
});
|
||||
expect(parseArticleDocument(parsed?.document)?.document).toEqual(
|
||||
parsed?.document,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unknown link metadata and non-default ordered lists', () => {
|
||||
const documentWith = (content: unknown[]) => ({
|
||||
version: 1,
|
||||
document: { type: 'doc', content },
|
||||
});
|
||||
|
||||
expect(
|
||||
parseArticleDocument(
|
||||
documentWith([
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'unsafe metadata',
|
||||
marks: [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: 'https://shd.build',
|
||||
onclick: 'alert(1)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseArticleDocument(
|
||||
documentWith([
|
||||
{
|
||||
type: 'orderedList',
|
||||
attrs: { start: 0, type: 'decimal' },
|
||||
content: [],
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseArticleDocument(
|
||||
documentWith([
|
||||
{
|
||||
type: 'orderedList',
|
||||
attrs: { start: 1, type: 1 },
|
||||
content: [],
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects duplicate embed keys and unknown envelope fields', () => {
|
||||
const embed = {
|
||||
type: 'buildEmbed',
|
||||
|
||||
@@ -10,7 +10,7 @@ const EMBED_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{7,63}$/;
|
||||
|
||||
export type ArticleMark = {
|
||||
type: 'bold' | 'italic' | 'code' | 'link';
|
||||
attrs?: { href: string };
|
||||
attrs?: { href: string; title?: string };
|
||||
};
|
||||
|
||||
export type ArticleNode = {
|
||||
@@ -53,7 +53,17 @@ const safeLink = (value: unknown) => {
|
||||
}
|
||||
};
|
||||
|
||||
function parseMarks(value: unknown): ArticleMark[] | null {
|
||||
const nullableBoundedString = (value: unknown, maximum: number) =>
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
(typeof value === 'string' &&
|
||||
value.length <= maximum &&
|
||||
!/\p{Cc}/u.test(value));
|
||||
|
||||
function parseMarks(
|
||||
value: unknown,
|
||||
onLinkTitle: (title: string) => void,
|
||||
): ArticleMark[] | null {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value) || value.length > 4) return null;
|
||||
const seen = new Set<string>();
|
||||
@@ -77,10 +87,31 @@ function parseMarks(value: unknown): ArticleMark[] | null {
|
||||
!isRecord(candidate.attrs)
|
||||
)
|
||||
return null;
|
||||
if (!exactKeys(candidate.attrs, ['href'])) return null;
|
||||
if (
|
||||
!exactKeys(candidate.attrs, [
|
||||
'href',
|
||||
'target',
|
||||
'rel',
|
||||
'class',
|
||||
'title',
|
||||
]) ||
|
||||
!nullableBoundedString(candidate.attrs.target, 100) ||
|
||||
!nullableBoundedString(candidate.attrs.rel, 500) ||
|
||||
!nullableBoundedString(candidate.attrs.class, 500) ||
|
||||
!nullableBoundedString(candidate.attrs.title, 500)
|
||||
)
|
||||
return null;
|
||||
const href = safeLink(candidate.attrs.href);
|
||||
if (!href) return null;
|
||||
marks.push({ type: 'link', attrs: { href } });
|
||||
const title =
|
||||
typeof candidate.attrs.title === 'string'
|
||||
? candidate.attrs.title.normalize('NFKC').trim()
|
||||
: '';
|
||||
if (title) onLinkTitle(title);
|
||||
marks.push({
|
||||
type: 'link',
|
||||
attrs: { href, ...(title ? { title } : {}) },
|
||||
});
|
||||
}
|
||||
return marks;
|
||||
}
|
||||
@@ -134,7 +165,9 @@ export function parseArticleDocument(value: unknown): {
|
||||
if (/\p{Cc}/u.test(text.replace(/[\n\t]/g, ''))) return null;
|
||||
textLength += [...text].length;
|
||||
if (textLength > MAX_ARTICLE_TEXT_CHARACTERS) return null;
|
||||
const marks = parseMarks(candidate.marks);
|
||||
const marks = parseMarks(candidate.marks, (title) =>
|
||||
textParts.push(title),
|
||||
);
|
||||
if (!marks) return null;
|
||||
textParts.push(text);
|
||||
return marks.length ? { type, text, marks } : { type, text };
|
||||
@@ -211,6 +244,31 @@ export function parseArticleDocument(value: unknown): {
|
||||
)
|
||||
return null;
|
||||
attrs = { level: Number(candidate.attrs.level) };
|
||||
} else if (type === 'orderedList' && candidate.attrs !== undefined) {
|
||||
if (
|
||||
!isRecord(candidate.attrs) ||
|
||||
!exactKeys(candidate.attrs, ['start', 'type'])
|
||||
)
|
||||
return null;
|
||||
const start = candidate.attrs.start ?? 1;
|
||||
const listType = candidate.attrs.type;
|
||||
if (
|
||||
!Number.isInteger(start) ||
|
||||
Number(start) < 1 ||
|
||||
Number(start) > 1_000_000 ||
|
||||
(listType !== null &&
|
||||
listType !== undefined &&
|
||||
(typeof listType !== 'string' ||
|
||||
!['1', 'a', 'A', 'i', 'I'].includes(listType)))
|
||||
)
|
||||
return null;
|
||||
const canonicalType =
|
||||
typeof listType === 'string' && listType !== '1' ? listType : undefined;
|
||||
attrs = {
|
||||
...(Number(start) === 1 ? {} : { start: Number(start) }),
|
||||
...(canonicalType ? { type: canonicalType } : {}),
|
||||
};
|
||||
if (!Object.keys(attrs).length) attrs = undefined;
|
||||
} else if (candidate.attrs !== undefined) {
|
||||
if (!isRecord(candidate.attrs) || Object.keys(candidate.attrs).length)
|
||||
return null;
|
||||
|
||||
@@ -39,6 +39,7 @@ describe('summarizeLoadout', () => {
|
||||
it('returns an empty deterministic summary for a partial draft', () => {
|
||||
expect(summarizeLoadout(createDraft(), catalog())).toEqual({
|
||||
stats: [],
|
||||
abilityStats: [],
|
||||
activeBonuses: [],
|
||||
equippedTalents: [],
|
||||
});
|
||||
@@ -214,6 +215,108 @@ describe('summarizeLoadout', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps Skill Chip primaries ability-scoped while adding secondaries to Agent stats', () => {
|
||||
const draft = createDraft();
|
||||
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-1',
|
||||
twoPiece: null,
|
||||
talent: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
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.each([
|
||||
[0, 10],
|
||||
[1, 20],
|
||||
[2, 30],
|
||||
[3, 40],
|
||||
])(
|
||||
'scales a three-chip 2-piece bonus with %i High-end chips',
|
||||
(highEndCount, expectedValue) => {
|
||||
const draft = createDraft();
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
draft.chips[index].setId = 'chip-set';
|
||||
draft.chips[index].quality =
|
||||
index < highEndCount ? 'High-end' : 'Standard';
|
||||
}
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [
|
||||
attribute('skill-damage', 'Skill Damage', { unit: '%' }),
|
||||
],
|
||||
chipSets: [
|
||||
{
|
||||
id: 'chip-set',
|
||||
name: 'Signal',
|
||||
specialization_ability_id: 'ability',
|
||||
twoPiece: {
|
||||
stat_attribute_id: 'skill-damage',
|
||||
min_value: 10,
|
||||
max_value: 40,
|
||||
},
|
||||
talent: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stat(summary, 'skill-damage')?.value).toBe(expectedValue);
|
||||
expect(summary.activeBonuses[0]).toMatchObject({
|
||||
count: 3,
|
||||
value: expectedValue,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('scales Protocol primary stats and Talent descriptions independently', () => {
|
||||
const draft = createDraft();
|
||||
draft.protocol = { itemId: 'protocol', level: 50, talentUpgrades: 5 };
|
||||
|
||||
@@ -7,6 +7,10 @@ export type LoadoutCatalog = {
|
||||
brands: any[];
|
||||
weapons: any[];
|
||||
talents: any[];
|
||||
skillChipProfile?: {
|
||||
primaryOptions: any[];
|
||||
secondaryOptions: any[];
|
||||
};
|
||||
chipSets: any[];
|
||||
protocols: any[];
|
||||
specializations: any[];
|
||||
@@ -14,6 +18,20 @@ export type LoadoutCatalog = {
|
||||
|
||||
export type LoadoutSummary = {
|
||||
stats: Array<{ id: string; value: number; sourceCount: number }>;
|
||||
abilityStats: Array<{
|
||||
abilityId: string;
|
||||
stats: Array<{
|
||||
id: string;
|
||||
value: number;
|
||||
sourceCount: number;
|
||||
sources: Array<{
|
||||
source: string;
|
||||
chipIndex: number;
|
||||
chipSetId: string;
|
||||
value: number;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
activeBonuses: any[];
|
||||
equippedTalents: any[];
|
||||
};
|
||||
@@ -139,6 +157,55 @@ export function summarizeLoadout(
|
||||
const brandCounts = new Map<string, number>();
|
||||
const activeBonuses: any[] = [];
|
||||
const equippedTalents: any[] = [];
|
||||
const abilityValues = new Map<string, Map<string, number>>();
|
||||
const abilitySources = new Map<
|
||||
string,
|
||||
Map<
|
||||
string,
|
||||
Array<{
|
||||
source: string;
|
||||
chipIndex: number;
|
||||
chipSetId: string;
|
||||
value: number;
|
||||
}>
|
||||
>
|
||||
>();
|
||||
const addAbilityStat = (
|
||||
abilityId: string,
|
||||
statId: string,
|
||||
value: unknown,
|
||||
chipIndex: number,
|
||||
chipSetId: string,
|
||||
) => {
|
||||
if (
|
||||
!abilityId ||
|
||||
!statId ||
|
||||
!catalogAttribute(catalog, statId) ||
|
||||
!Number.isFinite(Number(value))
|
||||
)
|
||||
return;
|
||||
if (!abilityValues.has(abilityId)) abilityValues.set(abilityId, new Map());
|
||||
const valuesForAbility = abilityValues.get(abilityId)!;
|
||||
valuesForAbility.set(
|
||||
statId,
|
||||
applyAttributeValue(
|
||||
catalog,
|
||||
valuesForAbility.get(statId) ?? 0,
|
||||
statId,
|
||||
Number(value),
|
||||
),
|
||||
);
|
||||
if (!abilitySources.has(abilityId))
|
||||
abilitySources.set(abilityId, new Map());
|
||||
const sourcesForAbility = abilitySources.get(abilityId)!;
|
||||
if (!sourcesForAbility.has(statId)) sourcesForAbility.set(statId, []);
|
||||
sourcesForAbility.get(statId)!.push({
|
||||
source: `Skill Chip ${chipIndex + 1} primary`,
|
||||
chipIndex,
|
||||
chipSetId,
|
||||
value: Number(value),
|
||||
});
|
||||
};
|
||||
|
||||
for (const slot of gearSlots) {
|
||||
const selection = draft.gear[slot];
|
||||
@@ -259,16 +326,35 @@ export function summarizeLoadout(
|
||||
string,
|
||||
{ count: number; highEnd: number; item: any }
|
||||
>();
|
||||
const validChipRoll = (
|
||||
kind: 'primaryOptions' | 'secondaryOptions',
|
||||
quality: string,
|
||||
roll: { statId: string; value: number },
|
||||
) => {
|
||||
if (!catalog.skillChipProfile) return true;
|
||||
const range = catalog.skillChipProfile[kind].find(
|
||||
(option) => option.statId === roll.statId,
|
||||
)?.ranges?.[quality];
|
||||
return (
|
||||
range &&
|
||||
roll.value >= Number(range.min) &&
|
||||
roll.value <= Number(range.max)
|
||||
);
|
||||
};
|
||||
for (let index = 0; index < 9; index += 1) {
|
||||
const selection = draft.chips[index];
|
||||
const item = catalog.chipSets.find((entry) => entry.id === selection.setId);
|
||||
if (!item) continue;
|
||||
add(
|
||||
if (validChipRoll('primaryOptions', selection.quality, selection.primary))
|
||||
addAbilityStat(
|
||||
String(item.specialization_ability_id ?? ''),
|
||||
selection.primary.statId,
|
||||
selection.primary.value,
|
||||
`Skill Chip ${index + 1} primary`,
|
||||
index,
|
||||
String(item.id),
|
||||
);
|
||||
for (const roll of selection.secondary)
|
||||
if (validChipRoll('secondaryOptions', selection.quality, roll))
|
||||
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
|
||||
const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item };
|
||||
group.count += 1;
|
||||
@@ -390,6 +476,28 @@ export function summarizeLoadout(
|
||||
catalogAttribute(catalog, right.id)?.name ?? '',
|
||||
),
|
||||
),
|
||||
abilityStats: [...abilityValues].map(([abilityId, abilityStatValues]) => ({
|
||||
abilityId,
|
||||
stats: [...abilityStatValues]
|
||||
.map(([id, value]) => {
|
||||
const statSources = abilitySources.get(abilityId)?.get(id) ?? [];
|
||||
return {
|
||||
id,
|
||||
value,
|
||||
sourceCount: statSources.length,
|
||||
sources: statSources,
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
(left, right) =>
|
||||
(catalogAttribute(catalog, left.id)?.category ?? '').localeCompare(
|
||||
catalogAttribute(catalog, right.id)?.category ?? '',
|
||||
) ||
|
||||
(catalogAttribute(catalog, left.id)?.name ?? '').localeCompare(
|
||||
catalogAttribute(catalog, right.id)?.name ?? '',
|
||||
),
|
||||
),
|
||||
})),
|
||||
activeBonuses,
|
||||
equippedTalents,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createDraft, parseLoadoutDraft } from './loadout.js';
|
||||
import {
|
||||
createDraft,
|
||||
parseLoadoutDraft,
|
||||
skillChipSecondaryCapacity,
|
||||
type SkillChipQuality,
|
||||
} from './loadout.js';
|
||||
|
||||
const copyDraft = () => structuredClone(createDraft());
|
||||
|
||||
@@ -9,6 +14,13 @@ describe('parseLoadoutDraft', () => {
|
||||
const parsed = parseLoadoutDraft(draft);
|
||||
|
||||
expect(parsed).toEqual(draft);
|
||||
expect(parsed?.version).toBe(5);
|
||||
expect(parsed?.chips[0]).toEqual({
|
||||
setId: '',
|
||||
quality: 'Standard',
|
||||
primary: { statId: '', value: 0 },
|
||||
secondary: [],
|
||||
});
|
||||
expect(parsed?.calculationMode).toBe('maximum');
|
||||
expect(parsed).not.toBe(draft);
|
||||
parsed!.gear.Holster.level = 1;
|
||||
@@ -23,13 +35,61 @@ describe('parseLoadoutDraft', () => {
|
||||
expect(parseLoadoutDraft(retired)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a version 4 draft that does not contain exactly nine chips', () => {
|
||||
it('rejects the retired version 4 schema', () => {
|
||||
const retired = copyDraft() as unknown as Record<string, unknown>;
|
||||
retired.version = 4;
|
||||
|
||||
expect(parseLoadoutDraft(retired)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a version 5 draft that does not contain exactly nine chips', () => {
|
||||
const draft = copyDraft();
|
||||
draft.chips.pop();
|
||||
|
||||
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Standard', 0],
|
||||
['Specialized', 1],
|
||||
['Superior', 2],
|
||||
['High-end', 2],
|
||||
] as const)(
|
||||
'accepts %s chips with exactly %i secondary rolls',
|
||||
(quality, capacity) => {
|
||||
const draft = copyDraft();
|
||||
draft.chips[0].quality = quality;
|
||||
draft.chips[0].primary = { statId: 'primary', value: 10 };
|
||||
draft.chips[0].secondary = Array.from(
|
||||
{ length: capacity },
|
||||
(_, index) => ({ statId: `secondary-${index}`, value: index + 1 }),
|
||||
);
|
||||
|
||||
expect(skillChipSecondaryCapacity[quality]).toBe(capacity);
|
||||
expect(parseLoadoutDraft(draft)).toEqual(draft);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['Standard', 1],
|
||||
['Specialized', 0],
|
||||
['Specialized', 2],
|
||||
['Superior', 1],
|
||||
['High-end', 3],
|
||||
] as Array<[SkillChipQuality, number]>)(
|
||||
'rejects %s chips with %i secondary rolls',
|
||||
(quality, capacity) => {
|
||||
const draft = copyDraft();
|
||||
draft.chips[0].quality = quality;
|
||||
draft.chips[0].secondary = Array.from(
|
||||
{ length: capacity },
|
||||
(_, index) => ({ statId: `secondary-${index}`, value: index + 1 }),
|
||||
);
|
||||
|
||||
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects a draft missing the unified combat profile', () => {
|
||||
const draft = copyDraft() as unknown as Record<string, unknown>;
|
||||
delete draft.combatProfile;
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
export type Roll = { statId: string; value: number };
|
||||
export const skillChipQualities = [
|
||||
'Standard',
|
||||
'Specialized',
|
||||
'Superior',
|
||||
'High-end',
|
||||
] as const;
|
||||
export type SkillChipQuality = (typeof skillChipQualities)[number];
|
||||
export const skillChipSecondaryCapacity: Record<SkillChipQuality, number> = {
|
||||
Standard: 0,
|
||||
Specialized: 1,
|
||||
Superior: 2,
|
||||
'High-end': 2,
|
||||
};
|
||||
export type GearSelection = {
|
||||
itemId: string;
|
||||
level: number;
|
||||
@@ -26,9 +39,9 @@ export type WeaponSelection = {
|
||||
};
|
||||
export type ChipSelection = {
|
||||
setId: string;
|
||||
quality: 'Superior' | 'High-end';
|
||||
quality: SkillChipQuality;
|
||||
primary: Roll;
|
||||
secondary: [Roll, Roll];
|
||||
secondary: Roll[];
|
||||
};
|
||||
export type ProtocolSelection = {
|
||||
itemId: string;
|
||||
@@ -68,7 +81,7 @@ export type SpecializationSelection = {
|
||||
nodeRanks: Record<string, number>;
|
||||
};
|
||||
export type LoadoutDraft = {
|
||||
version: 4;
|
||||
version: 5;
|
||||
buildName: string;
|
||||
calculationMode: CalculationMode;
|
||||
gear: Record<string, GearSelection>;
|
||||
@@ -127,9 +140,9 @@ export const blankWeapon = (): WeaponSelection => ({
|
||||
});
|
||||
export const blankChip = (): ChipSelection => ({
|
||||
setId: '',
|
||||
quality: 'Superior',
|
||||
quality: 'Standard',
|
||||
primary: blankRoll(),
|
||||
secondary: [blankRoll(), blankRoll()],
|
||||
secondary: [],
|
||||
});
|
||||
export const defaultCombatProfile = (): CombatProfile => ({
|
||||
encounterSeconds: 60,
|
||||
@@ -148,7 +161,7 @@ export const defaultCombatProfile = (): CombatProfile => ({
|
||||
extraHealthActive: false,
|
||||
});
|
||||
export const createDraft = (): LoadoutDraft => ({
|
||||
version: 4,
|
||||
version: 5,
|
||||
buildName: 'Untitled loadout',
|
||||
calculationMode: 'maximum',
|
||||
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
|
||||
@@ -242,9 +255,12 @@ const isChipSelection = (value: unknown): value is ChipSelection =>
|
||||
isRecord(value) &&
|
||||
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
|
||||
typeof value.setId === 'string' &&
|
||||
(value.quality === 'Superior' || value.quality === 'High-end') &&
|
||||
skillChipQualities.some((quality) => value.quality === quality) &&
|
||||
isRoll(value.primary) &&
|
||||
isRollPair(value.secondary);
|
||||
Array.isArray(value.secondary) &&
|
||||
value.secondary.length ===
|
||||
skillChipSecondaryCapacity[value.quality as SkillChipQuality] &&
|
||||
value.secondary.every(isRoll);
|
||||
const isProtocolSelection = (value: unknown): value is ProtocolSelection =>
|
||||
isRecord(value) &&
|
||||
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) &&
|
||||
@@ -353,7 +369,7 @@ export function parseLoadoutDraft(value: unknown): LoadoutDraft | null {
|
||||
'skillActivations',
|
||||
'specialization',
|
||||
]) ||
|
||||
value.version !== 4 ||
|
||||
value.version !== 5 ||
|
||||
typeof value.buildName !== 'string' ||
|
||||
!['maximum', 'custom'].includes(String(value.calculationMode))
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
currentHealthPercentBandPredicate,
|
||||
eventFieldComparisonPredicate,
|
||||
mechanicTargetingForMode,
|
||||
mechanicTargetingMode,
|
||||
parseConditionalMechanicDraft,
|
||||
readCurrentHealthPercentBand,
|
||||
readEventFieldComparison,
|
||||
@@ -200,6 +202,110 @@ describe('conditional mechanic authoring payload', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes assigned-ability activity for kill and assist event conditions', () => {
|
||||
expect(registeredEventFields('enemy_killed')).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
path: 'values.assignedAbilityActive',
|
||||
label: 'Assigned ability active',
|
||||
type: 'boolean',
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(registeredEventFields('assist')).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
path: 'values.assignedAbilityActive',
|
||||
label: 'Assigned ability active',
|
||||
type: 'boolean',
|
||||
},
|
||||
]),
|
||||
);
|
||||
const value = draft();
|
||||
value.clauses[0].event = 'enemy_killed';
|
||||
value.clauses[0].recipeKey = undefined;
|
||||
value.clauses[0].reducer = { kind: 'none' };
|
||||
value.clauses[0].if = eventFieldComparisonPredicate({
|
||||
path: 'values.assignedAbilityActive',
|
||||
operator: 'eq',
|
||||
value: true,
|
||||
});
|
||||
|
||||
const result = parseConditionalMechanicDraft(value, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage']),
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.programs.I.clauses[0]).toMatchObject({
|
||||
when: 'enemy_killed',
|
||||
if: value.clauses[0].if,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses deterministic outcome and clause proc chances with ability mutations', () => {
|
||||
const value = draft();
|
||||
value.clauses[0].event = 'skill_hit';
|
||||
value.clauses[0].recipeKey = undefined;
|
||||
value.clauses[0].procChancePercent = 10;
|
||||
value.clauses[0].requiresAssignedAbilityActive = true;
|
||||
value.clauses[0].outcomes[0].probabilityPercent = 29;
|
||||
value.clauses[0].outcomes[0].statePool = undefined;
|
||||
value.clauses[0].outcomes[0].perStack = false;
|
||||
value.pools = [
|
||||
{
|
||||
key: 'extension-stacks',
|
||||
label: 'Extension stacks',
|
||||
stateKind: 'stack',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 8,
|
||||
},
|
||||
{
|
||||
key: 'extension-cooldown',
|
||||
label: 'Extension cooldown',
|
||||
stateKind: 'flag',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
expiresAfterSeconds: 15,
|
||||
},
|
||||
];
|
||||
value.clauses[0].mutations = [
|
||||
{ kind: 'add', pool: 'extension-stacks', value: 1 },
|
||||
{ kind: 'extend_duration', target: 'assigned_ability', seconds: 0.3 },
|
||||
{
|
||||
kind: 'start_cooldown_on_max',
|
||||
stackPool: 'extension-stacks',
|
||||
cooldownPool: 'extension-cooldown',
|
||||
},
|
||||
];
|
||||
const result = parseConditionalMechanicDraft(value, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage']),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.programs.I.clauses[0]).toMatchObject({
|
||||
procChancePercent: 10,
|
||||
requiresAssignedAbilityActive: true,
|
||||
outcomes: [{ probabilityPercent: 29 }],
|
||||
});
|
||||
expect(result.value.programs.I.clauses[0].mutations).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: 'extend_duration', target: 'assigned_ability', seconds: 0.3 },
|
||||
{
|
||||
kind: 'start_cooldown_on_max',
|
||||
stackPool: 'extension-stacks',
|
||||
cooldownPool: 'extension-cooldown',
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unregistered Extra Health event field deterministically', () => {
|
||||
const value = draft();
|
||||
value.clauses[0].event = 'extra_health_changed';
|
||||
@@ -735,4 +841,125 @@ describe('conditional mechanic authoring payload', () => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes aiming and round-trips event-target explosion editor state', () => {
|
||||
expect(registeredEventFields('weapon_hit')).toContainEqual({
|
||||
path: 'values.aiming',
|
||||
label: 'Aiming down sights',
|
||||
type: 'boolean',
|
||||
});
|
||||
const targeting = mechanicTargetingForMode('event_target_explosion');
|
||||
expect(targeting).toEqual({
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 5,
|
||||
});
|
||||
expect(mechanicTargetingMode(targeting)).toBe('event_target_explosion');
|
||||
expect(mechanicTargetingForMode('none', targeting)).toBeUndefined();
|
||||
|
||||
const valid = draft();
|
||||
valid.clauses[0].if = eventFieldComparisonPredicate({
|
||||
path: 'values.aiming',
|
||||
operator: 'eq',
|
||||
value: true,
|
||||
});
|
||||
valid.clauses[0].outcomes[0] = {
|
||||
...valid.clauses[0].outcomes[0],
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
phase: 'after_event',
|
||||
attributeId: undefined,
|
||||
targeting,
|
||||
};
|
||||
const parsed = parseConditionalMechanicDraft(valid, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage']),
|
||||
});
|
||||
expect(parsed.ok).toBe(true);
|
||||
if (parsed.ok) {
|
||||
expect(parsed.value.draft.clauses[0].outcomes[0].targeting).toEqual(
|
||||
targeting,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects incomplete, mis-scoped, and non-event area damage', () => {
|
||||
for (const configure of [
|
||||
(candidate: ConditionalMechanicDraftV1) => {
|
||||
candidate.clauses[0].outcomes[0].targeting = {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 0,
|
||||
};
|
||||
},
|
||||
(candidate: ConditionalMechanicDraftV1) => {
|
||||
candidate.clauses[0].outcomes[0].scope = 'self';
|
||||
},
|
||||
(candidate: ConditionalMechanicDraftV1) => {
|
||||
candidate.clauses[0].outcomes[0].phase = 'while_active';
|
||||
},
|
||||
(candidate: ConditionalMechanicDraftV1) => {
|
||||
candidate.clauses[0].event = 'enemy_killed';
|
||||
},
|
||||
]) {
|
||||
const invalid = draft();
|
||||
invalid.clauses[0].outcomes[0] = {
|
||||
...invalid.clauses[0].outcomes[0],
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
phase: 'after_event',
|
||||
attributeId: undefined,
|
||||
targeting: {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 5,
|
||||
},
|
||||
};
|
||||
configure(invalid);
|
||||
expect(
|
||||
parseConditionalMechanicDraft(invalid, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage']),
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('requires explicit equality for optional aiming evidence', () => {
|
||||
for (const predicate of [
|
||||
{
|
||||
kind: 'compare' as const,
|
||||
left: { source: 'event' as const, path: 'values.aiming' },
|
||||
operator: 'ne' as const,
|
||||
right: true,
|
||||
},
|
||||
{
|
||||
kind: 'not' as const,
|
||||
predicate: {
|
||||
kind: 'compare' as const,
|
||||
left: { source: 'event' as const, path: 'values.aiming' },
|
||||
operator: 'eq' as const,
|
||||
right: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'compare' as const,
|
||||
left: { source: 'event' as const, path: 'values.aiming' },
|
||||
operator: 'ne' as const,
|
||||
right: { source: 'combat' as const, path: 'currentHealth' },
|
||||
},
|
||||
]) {
|
||||
const invalid = draft();
|
||||
invalid.clauses[0].if = predicate;
|
||||
expect(
|
||||
parseConditionalMechanicDraft(invalid, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage']),
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import {
|
||||
MECHANIC_EVENT_KINDS,
|
||||
MECHANIC_EVENT_REGISTRY,
|
||||
mechanicOutcomeTargetingError,
|
||||
validateMechanicProgram,
|
||||
type ClauseMutation,
|
||||
type MechanicClause,
|
||||
type MechanicEventKind,
|
||||
type MechanicOutcome,
|
||||
type MechanicOutcomeScope,
|
||||
type MechanicOutcomeTargeting,
|
||||
type MechanicProgram,
|
||||
type MechanicResource,
|
||||
type NumericExpression,
|
||||
@@ -198,6 +200,44 @@ export function registeredEventFields(event: MechanicEventKind) {
|
||||
return MECHANIC_EVENT_REGISTRY[event].fields ?? [];
|
||||
}
|
||||
|
||||
export type MechanicTargetingMode =
|
||||
| 'none'
|
||||
| 'allies_within_radius'
|
||||
| 'enemies_within_radius'
|
||||
| 'event_target_explosion';
|
||||
|
||||
export function mechanicTargetingMode(
|
||||
targeting: MechanicOutcomeTargeting | undefined,
|
||||
): MechanicTargetingMode {
|
||||
if (!targeting) return 'none';
|
||||
if (targeting.kind === 'event_target_area') return 'event_target_explosion';
|
||||
return targeting.population === 'allies'
|
||||
? 'allies_within_radius'
|
||||
: 'enemies_within_radius';
|
||||
}
|
||||
|
||||
export function mechanicTargetingForMode(
|
||||
mode: MechanicTargetingMode,
|
||||
current?: MechanicOutcomeTargeting,
|
||||
): MechanicOutcomeTargeting | undefined {
|
||||
if (mode === 'none') return undefined;
|
||||
const radiusMeters =
|
||||
current?.radiusMeters ?? (mode === 'event_target_explosion' ? 5 : 20);
|
||||
if (mode === 'event_target_explosion') {
|
||||
return {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'within_radius',
|
||||
population: mode === 'allies_within_radius' ? 'allies' : 'enemies',
|
||||
radiusMeters,
|
||||
};
|
||||
}
|
||||
|
||||
export type StatePoolThresholdComparison = {
|
||||
pool: string;
|
||||
operator: PredicateComparisonOperator;
|
||||
@@ -258,6 +298,7 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = {
|
||||
'maxStacks',
|
||||
'targeting',
|
||||
'descriptionSlot',
|
||||
'probabilityPercent',
|
||||
],
|
||||
phase: ['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active'],
|
||||
kind: [
|
||||
@@ -291,6 +332,25 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = {
|
||||
continuous:
|
||||
'Valid only for restoration. The value is the total restoration, delivered at a constant rate across durationSeconds. Each activation creates an independent stream; overlapping streams add and overheal is lost when it occurs.',
|
||||
},
|
||||
probabilitySemantics:
|
||||
'probabilityPercent is an optional inclusive 0 through 100 percentage. In custom simulation, each eligible outcome rolls independently from the evaluation random stream. Maximum mode resolves every nonzero probability as successful.',
|
||||
targeting: {
|
||||
withinRadius: {
|
||||
kind: 'within_radius',
|
||||
population: ['allies', 'enemies'],
|
||||
radiusMeters: 'positive finite number',
|
||||
semantics:
|
||||
'Legacy radius metadata. It preserves authored range but does not select or fan out runtime targets.',
|
||||
},
|
||||
eventTargetArea: {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 'positive finite number',
|
||||
semantics:
|
||||
'Valid only for target-scoped direct damage in on_event or after_event phases on a weapon_hit clause. The triggering event must supply an authoritative event-target area sample. Every valid enemy at or inside the radius receives one equal damage emission, ordered by entity ID.',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
const phases = new Set<OutcomePhase>(
|
||||
CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.phase,
|
||||
@@ -371,6 +431,10 @@ function isReference(value: unknown): value is ValueReference {
|
||||
);
|
||||
case 'equipment':
|
||||
return typeof value.path === 'string' && Boolean(value.path.trim());
|
||||
case 'assigned_ability':
|
||||
return (
|
||||
value.path === 'active' || value.path === 'remainingDurationSeconds'
|
||||
);
|
||||
case 'attribute':
|
||||
return (
|
||||
typeof value.attributeId === 'string' &&
|
||||
@@ -527,6 +591,17 @@ function isMutation(value: unknown): value is ClauseMutation {
|
||||
isExpression(value.value))
|
||||
);
|
||||
}
|
||||
if (value.kind === 'extend_duration') {
|
||||
return value.target === 'assigned_ability' && isExpression(value.seconds);
|
||||
}
|
||||
if (value.kind === 'start_cooldown_on_max') {
|
||||
return (
|
||||
typeof value.stackPool === 'string' &&
|
||||
Boolean(value.stackPool.trim()) &&
|
||||
typeof value.cooldownPool === 'string' &&
|
||||
Boolean(value.cooldownPool.trim())
|
||||
);
|
||||
}
|
||||
return (
|
||||
['add', 'set', 'consume', 'derive'].includes(value.kind) &&
|
||||
typeof value.pool === 'string' &&
|
||||
@@ -724,30 +799,21 @@ function parseOutcome(
|
||||
) {
|
||||
errors.push(`${path} description slot must be a positive integer.`);
|
||||
}
|
||||
if (value.targeting !== undefined) {
|
||||
if (
|
||||
!object(value.targeting) ||
|
||||
value.targeting.kind !== 'within_radius' ||
|
||||
!['allies', 'enemies'].includes(String(value.targeting.population)) ||
|
||||
!finite(value.targeting.radiusMeters) ||
|
||||
value.targeting.radiusMeters <= 0
|
||||
value.probabilityPercent !== undefined &&
|
||||
(!finite(value.probabilityPercent) ||
|
||||
value.probabilityPercent < 0 ||
|
||||
value.probabilityPercent > 100)
|
||||
) {
|
||||
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.`);
|
||||
}
|
||||
errors.push(`${path} probability must be from 0 through 100 percent.`);
|
||||
}
|
||||
const targetingError = mechanicOutcomeTargetingError({
|
||||
kind,
|
||||
scope: value.scope as MechanicOutcomeScope,
|
||||
phase: value.phase as OutcomePhase,
|
||||
targeting: value.targeting,
|
||||
});
|
||||
if (targetingError) errors.push(`${path} ${targetingError}`);
|
||||
if (!object(value.variants)) {
|
||||
errors.push(`${path} variants must be an object.`);
|
||||
return null;
|
||||
@@ -828,6 +894,22 @@ function parseClauses(
|
||||
if (candidate.weight !== undefined && !isExpression(candidate.weight)) {
|
||||
errors.push(`${path} has an invalid weight expression.`);
|
||||
}
|
||||
if (
|
||||
candidate.procChancePercent !== undefined &&
|
||||
(!finite(candidate.procChancePercent) ||
|
||||
candidate.procChancePercent < 0 ||
|
||||
candidate.procChancePercent > 100)
|
||||
) {
|
||||
errors.push(`${path} proc chance must be from 0 through 100 percent.`);
|
||||
}
|
||||
if (
|
||||
candidate.requiresAssignedAbilityActive !== undefined &&
|
||||
typeof candidate.requiresAssignedAbilityActive !== 'boolean'
|
||||
) {
|
||||
errors.push(
|
||||
`${path} assigned-ability activity requirement must be a boolean.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
candidate.cooldownSeconds !== undefined &&
|
||||
(!finite(candidate.cooldownSeconds) || candidate.cooldownSeconds <= 0)
|
||||
@@ -902,6 +984,15 @@ function programsForDraft(
|
||||
? {}
|
||||
: { cooldownSeconds: clause.cooldownSeconds }),
|
||||
...(clause.weight === undefined ? {} : { weight: clause.weight }),
|
||||
...(clause.procChancePercent === undefined
|
||||
? {}
|
||||
: { procChancePercent: clause.procChancePercent }),
|
||||
...(clause.requiresAssignedAbilityActive === undefined
|
||||
? {}
|
||||
: {
|
||||
requiresAssignedAbilityActive:
|
||||
clause.requiresAssignedAbilityActive,
|
||||
}),
|
||||
mutations: clause.mutations,
|
||||
outcomes: clause.outcomes.map(
|
||||
({
|
||||
|
||||
@@ -200,6 +200,143 @@ describe('conditional-effect persistence adapter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves event-target area damage semantics on an outcome', () => {
|
||||
const result = adaptConditionalEffectProgram(
|
||||
{
|
||||
rule: rule(),
|
||||
statePools: [],
|
||||
clauses: [
|
||||
clause({
|
||||
trigger_id: null,
|
||||
event_kind: 'weapon_hit',
|
||||
state_pool_id: null,
|
||||
mutations_json: '[]',
|
||||
}),
|
||||
],
|
||||
outcomes: [
|
||||
outcome({
|
||||
application_kind: 'direct_damage',
|
||||
effect_scope: 'target',
|
||||
stat_attribute_id: null,
|
||||
application_phase: 'after_event',
|
||||
state_pool_id: null,
|
||||
targeting_json: JSON.stringify({
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 5,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
triggers: [],
|
||||
},
|
||||
{ name: 'Critical Explosion', variantKey: 'I' },
|
||||
);
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.program?.clauses[0].outcomes?.[0]).toMatchObject({
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
phase: 'after_event',
|
||||
targeting: {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 5,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('adapts assigned-ability activity predicates for kill and assist events', () => {
|
||||
for (const eventKind of ['enemy_killed', 'assist'] as const) {
|
||||
const result = adaptConditionalEffectProgram(
|
||||
{
|
||||
rule: rule(),
|
||||
statePools: [],
|
||||
clauses: [
|
||||
clause({
|
||||
trigger_id: null,
|
||||
event_kind: eventKind,
|
||||
parameters_json: '{}',
|
||||
state_pool_id: null,
|
||||
mutations_json: '[]',
|
||||
conditions_json: JSON.stringify({
|
||||
kind: 'compare',
|
||||
left: {
|
||||
source: 'event',
|
||||
path: 'values.assignedAbilityActive',
|
||||
},
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
outcomes: [outcome({ state_pool_id: null })],
|
||||
triggers: [],
|
||||
},
|
||||
{ variantKey: 'I' },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ status: 'READY', errors: [] });
|
||||
expect(result.program?.clauses[0]).toMatchObject({
|
||||
when: eventKind,
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: {
|
||||
source: 'event',
|
||||
path: 'values.assignedAbilityActive',
|
||||
},
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves outcome probability metadata', () => {
|
||||
const result = adaptConditionalEffectProgram(
|
||||
{
|
||||
rule: rule(),
|
||||
statePools: [],
|
||||
clauses: [
|
||||
clause({
|
||||
trigger_id: null,
|
||||
event_kind: 'shot_fired',
|
||||
recipe_parameters_json: JSON.stringify({
|
||||
procChancePercent: 10,
|
||||
requiresAssignedAbilityActive: true,
|
||||
}),
|
||||
parameters_json: '{}',
|
||||
state_pool_id: null,
|
||||
mutations_json: '[]',
|
||||
}),
|
||||
],
|
||||
outcomes: [
|
||||
outcome({
|
||||
state_pool_id: null,
|
||||
application_kind: 'restore_ammo',
|
||||
effect_scope: 'self',
|
||||
stat_attribute_id: null,
|
||||
resource_kind: 'ammo',
|
||||
probability_percent: 29,
|
||||
}),
|
||||
],
|
||||
triggers: [],
|
||||
},
|
||||
{ variantKey: 'I' },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ status: 'READY', errors: [] });
|
||||
expect(result.program?.clauses[0].outcomes?.[0]).toMatchObject({
|
||||
probabilityPercent: 29,
|
||||
resource: 'ammo',
|
||||
});
|
||||
expect(result.program?.clauses[0]).toMatchObject({
|
||||
procChancePercent: 10,
|
||||
requiresAssignedAbilityActive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('loads continuous restoration delivery metadata', () => {
|
||||
const result = adaptConditionalEffectProgram(
|
||||
{
|
||||
|
||||
@@ -54,6 +54,7 @@ export type ConditionalEffectClauseRow = {
|
||||
event_kind?: DatabaseValue;
|
||||
recipe_key?: DatabaseValue;
|
||||
parameters_json: DatabaseValue;
|
||||
recipe_parameters_json?: DatabaseValue;
|
||||
conditions_json: DatabaseValue;
|
||||
reducer_json: DatabaseValue;
|
||||
weight_expression_json?: DatabaseValue;
|
||||
@@ -85,6 +86,7 @@ export type ConditionalEffectOutcomeRow = {
|
||||
delivery_mode?: DatabaseValue;
|
||||
delivery_duration_seconds?: DatabaseValue;
|
||||
targeting_json?: DatabaseValue;
|
||||
probability_percent?: DatabaseValue;
|
||||
value_scaling: DatabaseValue;
|
||||
unit: DatabaseValue;
|
||||
damage_basis: DatabaseValue;
|
||||
@@ -205,6 +207,9 @@ function isValueReference(value: unknown) {
|
||||
if (value.source === 'event' || value.source === 'equipment') {
|
||||
return typeof value.path === 'string' && Boolean(value.path.trim());
|
||||
}
|
||||
if (value.source === 'assigned_ability') {
|
||||
return value.path === 'active' || value.path === 'remainingDurationSeconds';
|
||||
}
|
||||
if (value.source === 'combat') {
|
||||
return (
|
||||
typeof value.path === 'string' &&
|
||||
@@ -313,6 +318,19 @@ function isMutation(value: unknown): value is ClauseMutation {
|
||||
isNumericExpression(value.value))
|
||||
);
|
||||
}
|
||||
if (value.kind === 'extend_duration') {
|
||||
return (
|
||||
value.target === 'assigned_ability' && isNumericExpression(value.seconds)
|
||||
);
|
||||
}
|
||||
if (value.kind === 'start_cooldown_on_max') {
|
||||
return (
|
||||
typeof value.stackPool === 'string' &&
|
||||
Boolean(value.stackPool.trim()) &&
|
||||
typeof value.cooldownPool === 'string' &&
|
||||
Boolean(value.cooldownPool.trim())
|
||||
);
|
||||
}
|
||||
return (
|
||||
['add', 'set', 'consume', 'derive'].includes(value.kind) &&
|
||||
typeof value.pool === 'string' &&
|
||||
@@ -731,6 +749,17 @@ function parseOutcome(
|
||||
if (delivery === 'instant' && durationSeconds !== null) {
|
||||
errors.push(`${path} instant delivery cannot set a duration.`);
|
||||
}
|
||||
const probabilityPercent = number(row.probability_percent);
|
||||
if (
|
||||
row.probability_percent !== undefined &&
|
||||
row.probability_percent !== null &&
|
||||
row.probability_percent !== '' &&
|
||||
(probabilityPercent === null ||
|
||||
probabilityPercent < 0 ||
|
||||
probabilityPercent > 100)
|
||||
) {
|
||||
errors.push(`${path} probability must be from 0 through 100 percent.`);
|
||||
}
|
||||
let targeting: MechanicOutcome['targeting'] | undefined;
|
||||
if (
|
||||
row.targeting_json !== null &&
|
||||
@@ -766,6 +795,7 @@ function parseOutcome(
|
||||
? { delivery, durationSeconds }
|
||||
: {}),
|
||||
...(targeting ? { targeting } : {}),
|
||||
...(probabilityPercent === null ? {} : { probabilityPercent }),
|
||||
...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id)
|
||||
? { attributeId: text(row.stat_attribute_id) }
|
||||
: {}),
|
||||
@@ -903,7 +933,7 @@ export function adaptConditionalEffectProgram(
|
||||
continue;
|
||||
}
|
||||
const parametersJson = parseJson(
|
||||
row.parameters_json,
|
||||
row.recipe_parameters_json ?? row.parameters_json,
|
||||
`${path} parameters`,
|
||||
errors,
|
||||
);
|
||||
@@ -976,6 +1006,25 @@ export function adaptConditionalEffectProgram(
|
||||
}
|
||||
}
|
||||
const recipeKey = nullableText(row.recipe_key);
|
||||
const procChancePercent = parametersJson.value.procChancePercent;
|
||||
if (
|
||||
procChancePercent !== undefined &&
|
||||
(!finite(procChancePercent) ||
|
||||
procChancePercent < 0 ||
|
||||
procChancePercent > 100)
|
||||
) {
|
||||
errors.push(`${path} proc chance must be from 0 through 100 percent.`);
|
||||
}
|
||||
const requiresAssignedAbilityActive =
|
||||
parametersJson.value.requiresAssignedAbilityActive;
|
||||
if (
|
||||
requiresAssignedAbilityActive !== undefined &&
|
||||
typeof requiresAssignedAbilityActive !== 'boolean'
|
||||
) {
|
||||
errors.push(
|
||||
`${path} assigned-ability activity requirement must be a boolean.`,
|
||||
);
|
||||
}
|
||||
clauses.push({
|
||||
id,
|
||||
label: text(row.label) || text(row.clause_key) || undefined,
|
||||
@@ -989,6 +1038,10 @@ export function adaptConditionalEffectProgram(
|
||||
? {}
|
||||
: { cooldownSeconds: number(row.cooldown_seconds) as number }),
|
||||
...(weight === undefined ? {} : { weight }),
|
||||
...(typeof procChancePercent === 'number' ? { procChancePercent } : {}),
|
||||
...(typeof requiresAssignedAbilityActive === 'boolean'
|
||||
? { requiresAssignedAbilityActive }
|
||||
: {}),
|
||||
mutations: mutationsJson.value,
|
||||
...(clauseOutcomes.length ? { outcomes: clauseOutcomes } : {}),
|
||||
});
|
||||
|
||||
@@ -19,6 +19,276 @@ const custom = (
|
||||
inputs,
|
||||
});
|
||||
|
||||
describe('deterministic mechanic probabilities and assigned abilities', () => {
|
||||
it('rolls outcome probability independently with a reproducible seed and preserves a refreshed window', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'ammo-refill',
|
||||
name: 'Ammo Refill',
|
||||
pools: [
|
||||
{
|
||||
key: 'tactical-link-window',
|
||||
label: 'Tactical Link window',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
expiresAfterSeconds: 6,
|
||||
},
|
||||
],
|
||||
clauses: [
|
||||
{
|
||||
id: 'start-window',
|
||||
when: 'signature_ability_deployed',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'set', pool: 'tactical-link-window', value: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'refresh-window',
|
||||
when: 'enemy_killed',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'set', pool: 'tactical-link-window', value: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'recover-ammo',
|
||||
when: 'shot_fired',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: { source: 'pool', pool: 'tactical-link-window' },
|
||||
operator: 'eq',
|
||||
right: 1,
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [
|
||||
{
|
||||
id: 'recover-one-ammo',
|
||||
label: 'Recover one ammo',
|
||||
phase: 'after_event',
|
||||
kind: 'restoration',
|
||||
scope: 'self',
|
||||
basis: 'flat',
|
||||
resource: 'ammo',
|
||||
value: 1,
|
||||
probabilityPercent: 29,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const events = [
|
||||
{
|
||||
kind: 'signature_ability_deployed' as const,
|
||||
at: 0,
|
||||
values: { assignedAbilityDurationSeconds: 6 },
|
||||
},
|
||||
{ kind: 'shot_fired' as const, at: 1 },
|
||||
{ kind: 'shot_fired' as const, at: 2 },
|
||||
{ kind: 'enemy_killed' as const, at: 5 },
|
||||
{ kind: 'shot_fired' as const, at: 6 },
|
||||
{ kind: 'shot_fired' as const, at: 11 },
|
||||
];
|
||||
const first = evaluateMechanicProgram(
|
||||
program,
|
||||
events,
|
||||
{ mode: 'custom', encounterSeconds: 12, randomSeed: 1 },
|
||||
{ maxHealth: 100, currentHealth: 100, maxAmmo: 20, currentAmmo: 5 },
|
||||
);
|
||||
const second = evaluateMechanicProgram(
|
||||
program,
|
||||
events,
|
||||
{ mode: 'custom', encounterSeconds: 12, randomSeed: 1 },
|
||||
{ maxHealth: 100, currentHealth: 100, maxAmmo: 20, currentAmmo: 5 },
|
||||
);
|
||||
|
||||
expect(first.status).toBe('OK');
|
||||
expect(first.randomSeed).toBe(1);
|
||||
expect(first.outcomes.map((outcome) => outcome.at)).toEqual([2]);
|
||||
expect(first.outcomes[0]).toMatchObject({
|
||||
probabilityPercent: 29,
|
||||
probabilityRoll: expect.any(Number),
|
||||
appliedValue: 1,
|
||||
});
|
||||
expect(first.finalCombat?.currentAmmo).toBe(6);
|
||||
expect(second.outcomes).toEqual(first.outcomes);
|
||||
});
|
||||
|
||||
it('gates a proc before mutations, extends a live assigned ability, and starts cooldown on the eighth stack', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'tactical-loop',
|
||||
name: 'Tactical Loop',
|
||||
pools: [
|
||||
{
|
||||
key: 'extension-stacks',
|
||||
label: 'Extension stacks',
|
||||
scope: 'agent',
|
||||
initial: 7,
|
||||
min: 0,
|
||||
max: 8,
|
||||
},
|
||||
{
|
||||
key: 'extension-cooldown',
|
||||
label: 'Extension cooldown',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
expiresAfterSeconds: 15,
|
||||
},
|
||||
],
|
||||
clauses: [
|
||||
{
|
||||
id: 'extend-tactical-link',
|
||||
when: 'skill_hit',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: { source: 'pool', pool: 'extension-cooldown' },
|
||||
operator: 'eq',
|
||||
right: 0,
|
||||
},
|
||||
requiresAssignedAbilityActive: true,
|
||||
procChancePercent: 10,
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [
|
||||
{ kind: 'add', pool: 'extension-stacks', value: 1 },
|
||||
{
|
||||
kind: 'extend_duration',
|
||||
target: 'assigned_ability',
|
||||
seconds: 0.3,
|
||||
},
|
||||
{
|
||||
kind: 'start_cooldown_on_max',
|
||||
stackPool: 'extension-stacks',
|
||||
cooldownPool: 'extension-cooldown',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const success = evaluateMechanicProgram(
|
||||
program,
|
||||
[
|
||||
{
|
||||
kind: 'signature_ability_deployed',
|
||||
at: 0,
|
||||
values: { assignedAbilityDurationSeconds: 6 },
|
||||
},
|
||||
{ kind: 'skill_hit', at: 5.9 },
|
||||
{ kind: 'skill_hit', at: 6.1 },
|
||||
],
|
||||
{ mode: 'custom', encounterSeconds: 7, randomSeed: 7 },
|
||||
);
|
||||
const failed = evaluateMechanicProgram(
|
||||
program,
|
||||
[
|
||||
{
|
||||
kind: 'signature_ability_deployed',
|
||||
at: 0,
|
||||
values: { assignedAbilityDurationSeconds: 6 },
|
||||
},
|
||||
{ kind: 'skill_hit', at: 5.9 },
|
||||
],
|
||||
{ mode: 'custom', encounterSeconds: 7, randomSeed: 1 },
|
||||
);
|
||||
|
||||
expect(success.status).toBe('OK');
|
||||
expect(success.emissions).toHaveLength(1);
|
||||
const extension = success.emissions[0].mutations.find(
|
||||
(mutation) => mutation.kind === 'extend_duration',
|
||||
);
|
||||
expect(extension).toMatchObject({ kind: 'extend_duration' });
|
||||
expect(extension?.before).toBeCloseTo(0.1);
|
||||
expect(extension?.after).toBeCloseTo(0.4);
|
||||
expect(success.emissions[0].mutations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: 'start_cooldown_on_max',
|
||||
pool: 'extension-cooldown',
|
||||
after: 1,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(success.finalState).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pool: 'extension-stacks',
|
||||
value: 8,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
pool: 'extension-cooldown',
|
||||
value: 1,
|
||||
expiresAt: 20.9,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(failed.emissions).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not extend an assigned ability at its exact expiry and rejects invalid chance data', () => {
|
||||
const boundaryProgram: MechanicProgram = {
|
||||
id: 'exact-expiry',
|
||||
name: 'Exact expiry',
|
||||
clauses: [
|
||||
{
|
||||
id: 'extend',
|
||||
when: 'skill_hit',
|
||||
requiresAssignedAbilityActive: true,
|
||||
procChancePercent: 100,
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [
|
||||
{
|
||||
kind: 'extend_duration',
|
||||
target: 'assigned_ability',
|
||||
seconds: 0.3,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(
|
||||
evaluateMechanicProgram(
|
||||
boundaryProgram,
|
||||
[
|
||||
{
|
||||
kind: 'signature_ability_deployed',
|
||||
at: 0,
|
||||
values: { assignedAbilityDurationSeconds: 1 },
|
||||
},
|
||||
{ kind: 'skill_hit', at: 1 },
|
||||
],
|
||||
{ mode: 'custom', encounterSeconds: 2, randomSeed: 7 },
|
||||
).emissions,
|
||||
).toEqual([]);
|
||||
|
||||
const invalid = structuredClone(boundaryProgram);
|
||||
invalid.clauses[0].procChancePercent = 101;
|
||||
expect(validateMechanicProgram(invalid)).toMatchObject({
|
||||
ok: false,
|
||||
errors: [
|
||||
expect.stringContaining('proc chance must be from 0 through 100'),
|
||||
],
|
||||
});
|
||||
const invalidOutcome = structuredClone(boundaryProgram);
|
||||
invalidOutcome.clauses[0].outcomes = [
|
||||
{
|
||||
id: 'bad-chance',
|
||||
label: 'Bad chance',
|
||||
phase: 'after_event',
|
||||
kind: 'marker',
|
||||
scope: 'self',
|
||||
basis: 'flat',
|
||||
value: 1,
|
||||
probabilityPercent: -1,
|
||||
},
|
||||
];
|
||||
expect(validateMechanicProgram(invalidOutcome)).toMatchObject({
|
||||
ok: false,
|
||||
errors: [
|
||||
expect.stringContaining('probability must be from 0 through 100'),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('structured mechanic runtime', () => {
|
||||
it('advertises the authoritative weapon-hit target and headshot fields', () => {
|
||||
expect(MECHANIC_EVENT_REGISTRY.weapon_hit).toMatchObject({
|
||||
@@ -32,6 +302,11 @@ describe('structured mechanic runtime', () => {
|
||||
},
|
||||
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' },
|
||||
{ path: 'values.critical', label: 'Critical hit', type: 'boolean' },
|
||||
{
|
||||
path: 'values.aiming',
|
||||
label: 'Aiming down sights',
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
path: 'values.behindCover',
|
||||
label: 'Agent behind cover',
|
||||
@@ -305,6 +580,161 @@ describe('structured mechanic runtime', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('matches kills and assists only while the assigned ability is active', () => {
|
||||
const weaponCriticalHitChance = 'weapon-critical-hit-chance';
|
||||
const activeOutcome = {
|
||||
id: 'tactical-link-critical-chance',
|
||||
label: 'Weapon Critical Hit Chance',
|
||||
phase: 'while_active' as const,
|
||||
kind: 'attribute_modifier' as const,
|
||||
scope: 'self' as const,
|
||||
basis: 'flat' as const,
|
||||
value: 10.2,
|
||||
attributeId: weaponCriticalHitChance,
|
||||
statePool: 'tactical-link-kill-bonus',
|
||||
};
|
||||
const activePredicate = {
|
||||
kind: 'compare' as const,
|
||||
left: {
|
||||
source: 'event' as const,
|
||||
path: 'values.assignedAbilityActive',
|
||||
},
|
||||
operator: 'eq' as const,
|
||||
right: true,
|
||||
};
|
||||
const program: MechanicProgram = {
|
||||
id: 'tactical-link-kill-bonus',
|
||||
name: 'Tactical Link Kill Bonus',
|
||||
pools: [
|
||||
{
|
||||
key: 'tactical-link-kill-bonus',
|
||||
label: 'Tactical Link kill bonus',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
expiresAfterSeconds: 10,
|
||||
},
|
||||
],
|
||||
clauses: [
|
||||
{
|
||||
id: 'kill-during-tactical-link',
|
||||
when: 'enemy_killed',
|
||||
if: activePredicate,
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [
|
||||
{ kind: 'set', pool: 'tactical-link-kill-bonus', value: 1 },
|
||||
],
|
||||
outcomes: [activeOutcome],
|
||||
},
|
||||
{
|
||||
id: 'assist-during-tactical-link',
|
||||
when: 'assist',
|
||||
if: activePredicate,
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [
|
||||
{ kind: 'set', pool: 'tactical-link-kill-bonus', value: 1 },
|
||||
],
|
||||
outcomes: [{ ...activeOutcome }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(validateMechanicProgram(program)).toMatchObject({ ok: true });
|
||||
const result = evaluateMechanicProgram(
|
||||
program,
|
||||
[
|
||||
{
|
||||
kind: 'enemy_killed',
|
||||
at: 0,
|
||||
values: { assignedAbilityActive: true },
|
||||
},
|
||||
{
|
||||
kind: 'assist',
|
||||
at: 4,
|
||||
values: { assignedAbilityActive: false },
|
||||
},
|
||||
{ kind: 'enemy_killed', at: 6 },
|
||||
{
|
||||
kind: 'assist',
|
||||
at: 10,
|
||||
values: { assignedAbilityActive: true },
|
||||
},
|
||||
],
|
||||
custom(19),
|
||||
{
|
||||
maxHealth: 1_000,
|
||||
currentHealth: 1_000,
|
||||
attributes: { [weaponCriticalHitChance]: 0 },
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe('OK');
|
||||
expect(result.outcomes.map((outcome) => outcome.at)).toEqual([0, 10]);
|
||||
expect(result.finalState).toContainEqual(
|
||||
expect.objectContaining({
|
||||
pool: 'tactical-link-kill-bonus',
|
||||
value: 1,
|
||||
expiresAt: 20,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid assigned-ability activity predicates and event values', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'assigned-ability-activity',
|
||||
name: 'Assigned ability activity',
|
||||
clauses: [
|
||||
{
|
||||
id: 'kill',
|
||||
when: 'enemy_killed',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: {
|
||||
source: 'event',
|
||||
path: 'values.assignedAbilityActive',
|
||||
},
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
const invalidPredicate = structuredClone(program);
|
||||
invalidPredicate.clauses[0].if = {
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.assignedAbilityActive' },
|
||||
operator: 'eq',
|
||||
right: 'yes',
|
||||
};
|
||||
expect(validateMechanicProgram(invalidPredicate)).toMatchObject({
|
||||
ok: false,
|
||||
errors: [
|
||||
expect.stringContaining('Assigned ability active with a boolean value'),
|
||||
],
|
||||
});
|
||||
expect(
|
||||
evaluateMechanicProgram(
|
||||
program,
|
||||
[
|
||||
{
|
||||
kind: 'enemy_killed',
|
||||
at: 0,
|
||||
values: { assignedAbilityActive: 'yes' },
|
||||
} as unknown as MechanicEvent,
|
||||
],
|
||||
custom(1),
|
||||
),
|
||||
).toMatchObject({
|
||||
status: 'INVALID',
|
||||
errors: [
|
||||
expect.stringContaining('Assigned ability active must be a boolean'),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('executes imported talent trigger families as first-class events', () => {
|
||||
const eventKinds: MechanicEventKind[] = [
|
||||
'ally_downed',
|
||||
@@ -2550,4 +2980,251 @@ describe('mechanic validation and pseudo-script', () => {
|
||||
expect(result.script).toContain('TO ALLIES WITHIN 40m');
|
||||
expect(result.script).toContain('TO ENEMIES WITHIN 20m');
|
||||
});
|
||||
|
||||
it('applies aimed critical-hit area damage once to each valid enemy at or inside the radius', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'aimed-critical-explosion',
|
||||
name: 'Aimed Critical Explosion',
|
||||
clauses: [
|
||||
{
|
||||
id: 'aimed-critical-hit',
|
||||
when: 'weapon_hit',
|
||||
if: {
|
||||
kind: 'all',
|
||||
predicates: [
|
||||
{
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.critical' },
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
{
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.aiming' },
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [
|
||||
{
|
||||
id: 'explosion',
|
||||
label: 'Explosion',
|
||||
phase: 'after_event',
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
basis: 'flat',
|
||||
value: {
|
||||
op: 'multiply',
|
||||
left: 20,
|
||||
right: {
|
||||
ref: { source: 'attribute', attributeId: 'firepower' },
|
||||
},
|
||||
},
|
||||
targeting: {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 5,
|
||||
},
|
||||
probabilityPercent: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = evaluateMechanicProgram(
|
||||
program,
|
||||
[
|
||||
{
|
||||
kind: 'weapon_hit',
|
||||
at: 1,
|
||||
targetId: 'enemy-b',
|
||||
values: { critical: true, aiming: true },
|
||||
areaTargetSample: {
|
||||
center: 'event_target',
|
||||
validTargets: [
|
||||
{
|
||||
entityId: 'enemy-c',
|
||||
population: 'enemy',
|
||||
distanceMeters: 5.001,
|
||||
},
|
||||
{ entityId: 'enemy-b', population: 'enemy', distanceMeters: 0 },
|
||||
{ entityId: 'ally-a', population: 'ally', distanceMeters: 2 },
|
||||
{ entityId: 'enemy-a', population: 'enemy', distanceMeters: 5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
custom(5),
|
||||
{ maxHealth: 1000, currentHealth: 1000, attributes: { firepower: 100 } },
|
||||
);
|
||||
|
||||
expect(result.status).toBe('OK');
|
||||
expect(result.outcomes.map((outcome) => outcome.targetId)).toEqual([
|
||||
'enemy-a',
|
||||
'enemy-b',
|
||||
]);
|
||||
expect(result.outcomes.map((outcome) => outcome.appliedValue)).toEqual([
|
||||
2000, 2000,
|
||||
]);
|
||||
expect(
|
||||
new Set(result.outcomes.map((outcome) => outcome.probabilityRoll)).size,
|
||||
).toBe(1);
|
||||
expect(result.outcomes[0].probabilityRoll).not.toBeNull();
|
||||
expect(result.script).toContain(
|
||||
'EXPLOSION CENTERED ON EVENT TARGET; APPLY TO VALID ENEMIES WITHIN 5m',
|
||||
);
|
||||
const maximum = evaluateMechanicProgram(
|
||||
program,
|
||||
[
|
||||
{
|
||||
kind: 'weapon_hit',
|
||||
at: 1,
|
||||
targetId: 'enemy-b',
|
||||
values: { critical: true, aiming: true },
|
||||
areaTargetSample: {
|
||||
center: 'event_target',
|
||||
validTargets: [
|
||||
{ entityId: 'enemy-b', population: 'enemy', distanceMeters: 0 },
|
||||
{ entityId: 'enemy-a', population: 'enemy', distanceMeters: 5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
{ mode: 'maximum', encounterSeconds: 5 },
|
||||
{ maxHealth: 1000, currentHealth: 1000, attributes: { firepower: 100 } },
|
||||
);
|
||||
expect(maximum.outcomes.map((outcome) => outcome.targetId)).toEqual([
|
||||
'enemy-a',
|
||||
'enemy-b',
|
||||
]);
|
||||
expect(
|
||||
maximum.outcomes.every((outcome) => outcome.probabilityRoll === 0),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
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
@@ -24,6 +24,16 @@
|
||||
function link(mark: ArticleMark) {
|
||||
return mark.type === 'link' ? mark.attrs?.href ?? '#' : '#';
|
||||
}
|
||||
|
||||
function orderedListStart(node: ArticleNode) {
|
||||
const start = Number(node.attrs?.start ?? 1);
|
||||
return Number.isInteger(start) && start > 1 ? start : undefined;
|
||||
}
|
||||
|
||||
function orderedListType(node: ArticleNode): 'a' | 'A' | 'i' | 'I' | undefined {
|
||||
const type = node.attrs?.type;
|
||||
return ['a', 'A', 'i', 'I'].includes(String(type)) ? type as 'a' | 'A' | 'i' | 'I' : undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet renderText(text: string, marks: ArticleMark[], index = 0)}
|
||||
@@ -32,7 +42,7 @@
|
||||
{#if mark.type === 'bold'}<strong>{@render renderText(text, marks, index + 1)}</strong>
|
||||
{:else if mark.type === 'italic'}<em>{@render renderText(text, marks, index + 1)}</em>
|
||||
{:else if mark.type === 'code'}<code>{@render renderText(text, marks, index + 1)}</code>
|
||||
{:else}<a href={link(mark)} rel="nofollow ugc noopener noreferrer">{@render renderText(text, marks, index + 1)}</a>{/if}
|
||||
{:else}<a href={link(mark)} title={mark.attrs?.title} rel="nofollow ugc noopener noreferrer">{@render renderText(text, marks, index + 1)}</a>{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -46,7 +56,7 @@
|
||||
{:else if node.type === 'heading' && Number(node.attrs?.level) === 2}<h2>{@render children(node)}</h2>
|
||||
{:else if node.type === 'heading'}<h3>{@render children(node)}</h3>
|
||||
{:else if node.type === 'bulletList'}<ul>{@render children(node)}</ul>
|
||||
{:else if node.type === 'orderedList'}<ol>{@render children(node)}</ol>
|
||||
{:else if node.type === 'orderedList'}<ol start={orderedListStart(node)} type={orderedListType(node)}>{@render children(node)}</ol>
|
||||
{:else if node.type === 'listItem'}<li>{@render children(node)}</li>
|
||||
{:else if node.type === 'blockquote'}<blockquote>{@render children(node)}</blockquote>
|
||||
{:else if node.type === 'horizontalRule'}<hr/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
|
||||
import ArticleContent from '$lib/components/ArticleContent.svelte';
|
||||
import ArticleDiscussion from '$lib/components/ArticleDiscussion.svelte';
|
||||
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
let { data, form = null } = $props();
|
||||
@@ -25,7 +25,7 @@
|
||||
<header class="article-reader-head">
|
||||
<div class="article-reader-channel"><span>{data.article.channel ?? (data.article.kind === 'guide' ? 'COMMUNITY GUIDE' : 'NEWS')}</span>{#if data.article.featured}<b>FEATURED</b>{/if}{#if data.article.recommended}<b>RECOMMENDED</b>{/if}</div>
|
||||
<h1>{data.article.title}</h1><p>{data.article.deck}</p>
|
||||
<div class="article-reader-byline">{#if data.article.source}<div class="article-source-identity"><strong>{data.article.source.name}</strong><span>{publicationMeta}</span></div>{:else}<AgentIdentityLink size="compact" linked={Boolean(data.article.authorPublicSlug)} agent={{id:data.article.ownerId,displayName:data.article.author,publicSlug:data.article.authorPublicSlug,avatarUrl:`/avatars/user/${data.article.ownerId}`}} meta={publicationMeta}/>{/if}<div>{#if data.article.kind === 'guide' && data.article.owner}<a class="secondary" href={`/guides/${encodeURIComponent(data.article.slug)}/edit`}>EDIT GUIDE</a>{:else if data.article.kind === 'news' && data.canManageNews && !data.article.source}<a class="secondary" href={`/news/${encodeURIComponent(data.article.slug)}/edit`}>OPEN NEWSROOM</a>{/if}</div></div>
|
||||
<div class="article-reader-byline">{#if data.article.source}<div class="article-source-identity"><strong>{data.article.source.name}</strong><span>{publicationMeta}</span></div>{:else}<AgentIdentityLink size="compact" linked={Boolean(data.article.authorPublicSlug)} agent={{id:data.article.ownerId,displayName:data.article.author,publicSlug:data.article.authorPublicSlug,avatarUrl:`/avatars/user/${data.article.ownerId}`}} meta={publicationMeta}/>{/if}<div>{#if data.article.kind === 'guide' && data.article.owner}<a class="secondary" href={`/guides/${encodeURIComponent(data.article.slug)}/edit`}>EDIT GUIDE</a>{:else if data.article.kind === 'news' && data.canManageNews && !data.article.source}<a class="secondary" href={`/news/${encodeURIComponent(data.article.slug)}/edit`}>Edit Article</a>{/if}</div></div>
|
||||
{#if data.article.source}<aside class="article-source-notice"><div><span>OFFICIAL SOURCE</span><strong>Originally published by {data.article.source.name}</strong></div><a href={data.article.source.url} target="_blank" rel="noopener noreferrer">READ ON UBISOFT</a></aside>{/if}
|
||||
{#if data.tags.length}<nav class="article-reader-tags" aria-label="Article tags">{#each data.tags as tag}<a href={`/${path}?tag=${encodeURIComponent(tag.slug)}`}>{tag.name}</a>{/each}</nav>{/if}
|
||||
</header>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import type { GameIconName } from '$lib/game-icons';
|
||||
import type { DatabaseItem } from '$lib/database/items';
|
||||
import MarkdownContent from './MarkdownContent.svelte';
|
||||
import WeaponModSlotIcon from './WeaponModSlotIcon.svelte';
|
||||
|
||||
let {
|
||||
item,
|
||||
@@ -16,9 +17,21 @@
|
||||
hideDiscussionUtility?: boolean;
|
||||
anchor?: boolean;
|
||||
} = $props();
|
||||
let protocolLevel = $state(1);
|
||||
let talentUpgrades = $state(0);
|
||||
|
||||
const number = (value: number) => Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, '');
|
||||
const valueWithUnit = (value: number, unit: string) => `${number(value)}${unit ? (unit === '%' ? '%' : ` ${unit}`) : ''}`;
|
||||
const protocolPrimaryValue = () => item.protocolPreview
|
||||
? item.protocolPreview.primaryBaseValue * (1 + (protocolLevel - 1) / 49)
|
||||
: 0;
|
||||
const talentDescription = () => item.protocolPreview?.talentDescription?.replace(/\{(\d+)\}/g, (placeholder, raw) => {
|
||||
const effect = item.protocolPreview?.talentEffects[Number(raw) - 1];
|
||||
return effect ? valueWithUnit(effect.baseValue * (1 + talentUpgrades / 5), effect.unit) : placeholder;
|
||||
});
|
||||
</script>
|
||||
|
||||
<article id={anchor ? `database-item-${item.id}` : undefined} class="database-item-card" class:talent-card={item.variant === 'talent'}>
|
||||
<article id={anchor ? `database-item-${item.id}` : undefined} class="database-item-card" class:talent-card={item.variant === 'talent'} class:protocol-quality-standard={item.topBorderTone === 'standard'} class:protocol-quality-specialized={item.topBorderTone === 'specialized'} class:protocol-quality-superior={item.topBorderTone === 'superior'} class:protocol-quality-highend={item.topBorderTone === 'highend'}>
|
||||
<header>
|
||||
{#if item.renderUrl}<img class="database-item-render" src={item.renderUrl} alt="" loading="lazy"/>{/if}
|
||||
<div class="database-item-icon">
|
||||
@@ -31,13 +44,38 @@
|
||||
{#if item.headerBadges?.length || item.headerBadge || item.headerMetaIcon}
|
||||
<div class="database-header-status">
|
||||
{#if item.headerMetaIcon}<GameIcon name={item.headerMetaIcon}/>{/if}
|
||||
{#each item.headerBadges ?? [] as badge}<span class="database-header-badge">{badge}</span>{/each}
|
||||
{#if item.headerBadge}<span class="database-header-badge">{item.headerBadge}</span>{/if}
|
||||
{#each item.headerBadges ?? [] as badge}<span class="database-header-badge" class:exotic={badge === 'EXOTIC'}>{badge}</span>{/each}
|
||||
{#if item.headerBadge}<span class="database-header-badge" class:protocol-badge-standard={item.headerBadgeTone === 'standard'} class:protocol-badge-specialized={item.headerBadgeTone === 'specialized'} class:protocol-badge-superior={item.headerBadgeTone === 'superior'} class:protocol-badge-highend={item.headerBadgeTone === 'highend'}>{item.headerBadge}</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
{#if item.badges?.length}<div class="database-item-badges">{#each item.badges as badge}<span class={badge.tone ?? ''}>{badge.label}</span>{/each}</div>{/if}
|
||||
{#if item.protocolPreview}
|
||||
<section class="database-protocol-preview">
|
||||
<div class="database-protocol-primary"><span>{item.protocolPreview.primaryStat}</span><b>{Number.isFinite(protocolPrimaryValue()) ? valueWithUnit(protocolPrimaryValue(), item.protocolPreview.primaryUnit) : 'NOT CONFIGURED'}</b></div>
|
||||
{#if item.protocolPreview.talentDescription}
|
||||
<div class="database-protocol-talent"><p>{talentDescription()}</p></div>
|
||||
{/if}
|
||||
<div class="database-protocol-controls"><label class="database-protocol-level"><span>LVL {protocolLevel}</span><input type="range" min="1" max="50" step="1" bind:value={protocolLevel} aria-label={`${item.name} level`}/></label>{#if item.protocolPreview.talentDescription}<div class="database-protocol-diamonds" role="group" aria-label={`${item.name} talent upgrades`}>{#each [1, 2, 3, 4, 5] as upgrade}<button type="button" class:active={talentUpgrades >= upgrade} aria-label={`Set talent upgrades to ${upgrade}`} aria-pressed={talentUpgrades === upgrade} onclick={() => talentUpgrades = talentUpgrades === upgrade ? 0 : upgrade}>◆</button>{/each}</div>{/if}</div>
|
||||
</section>
|
||||
{:else}
|
||||
<DatabaseFacts facts={item.facts} layout={item.factLayout}/>
|
||||
{/if}
|
||||
{#if item.weaponModSlots}
|
||||
<div class="database-weapon-mod-slots">
|
||||
<div><span>MOD SLOTS</span></div>
|
||||
{#if item.weaponModSlots.length}
|
||||
<ul aria-label={`${item.name} Weapon Mod slots`}>
|
||||
{#each item.weaponModSlots as modSlot}
|
||||
<li class:upgrade-slot={modSlot.unlockedAtTierOne} class:has-slot-icon={Boolean(modSlot.iconUrl)} title={`${modSlot.slot}${modSlot.unlockedAtTierOne ? ' - Unlocked when this weapon upgrades to Tier 1' : ''}`}>
|
||||
{#if modSlot.iconUrl}<WeaponModSlotIcon slot={modSlot.slot} iconUrl={modSlot.iconUrl}/><span class="sr-only">{modSlot.slot}</span>{:else}<span>{modSlot.slot}</span>{/if}
|
||||
{#if modSlot.unlockedAtTierOne}<small>T1+</small>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}<p>MOD SLOT DATA NOT CONFIGURED</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if item.lines?.length}
|
||||
<div class="database-item-lines">{#each item.lines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>
|
||||
{/if}
|
||||
@@ -53,7 +91,7 @@
|
||||
<section><span>TIER I</span><p>{item.description ?? 'Description is not available.'}</p></section>
|
||||
<section><span>TIER II</span><p>{item.secondaryDescription ?? 'Description is not available.'}</p></section>
|
||||
</div>
|
||||
{:else}
|
||||
{:else if !item.protocolPreview}
|
||||
{#if item.description}<p class:data-description={item.descriptionStyle === 'data'}>{item.description}</p>{/if}
|
||||
{#if item.secondaryDescription}<p class="secondary-description" class:data-description={item.descriptionStyle === 'data'}>{item.secondaryDescription}</p>{/if}
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
import {
|
||||
MOD_QUALITIES,
|
||||
MOD_SECONDARY_CAPACITY,
|
||||
MOD_SECONDARY_QUALITIES,
|
||||
type ModSecondaryRange
|
||||
} from '$lib/mods/secondary-pool';
|
||||
|
||||
let {
|
||||
kind,
|
||||
pool,
|
||||
stats,
|
||||
error,
|
||||
saved = false,
|
||||
resultToken = ''
|
||||
}: {
|
||||
kind: 'Weapon Mod' | 'Gear Mod';
|
||||
pool: ModSecondaryRange[];
|
||||
stats: Array<{ id: string; name: string; unit: string }>;
|
||||
error?: string;
|
||||
saved?: boolean;
|
||||
resultToken?: string;
|
||||
} = $props();
|
||||
|
||||
const poolId = $derived(kind === 'Weapon Mod' ? 'weapon-mod-secondary-pool' : 'gear-mod-secondary-pool');
|
||||
const action = '?/saveSecondaryPool';
|
||||
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
|
||||
const blankSecondary = (): ModSecondaryRange => ({
|
||||
statId: '',
|
||||
values: Object.fromEntries(
|
||||
MOD_SECONDARY_QUALITIES.map((quality) => [quality, {
|
||||
level1Min: 0,
|
||||
level1Max: 0,
|
||||
level20Min: 0,
|
||||
level20Max: 0
|
||||
}])
|
||||
) as ModSecondaryRange['values']
|
||||
});
|
||||
let editing = $state(false);
|
||||
let draft = $state<ModSecondaryRange[]>([]);
|
||||
let handledResultToken = $state('');
|
||||
|
||||
function openEditor() {
|
||||
draft = pool.length ? structuredClone(pool) : [blankSecondary(), blankSecondary()];
|
||||
editing = true;
|
||||
}
|
||||
|
||||
function addSecondary() {
|
||||
if (draft.length < 50) draft = [...draft, blankSecondary()];
|
||||
}
|
||||
|
||||
function removeSecondary(index: number) {
|
||||
if (draft.length > 2) draft = draft.filter((_, itemIndex) => itemIndex !== index);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (saved && resultToken && resultToken !== handledResultToken) {
|
||||
handledResultToken = resultToken;
|
||||
editing = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="mod-secondary-pool-registry">
|
||||
<div class="selection-head">
|
||||
<div>
|
||||
<span>SHARED {kind.toUpperCase()} SECONDARY POOL</span>
|
||||
<small>ONE CANONICAL POOL FOR EVERY REGULAR {kind.toUpperCase()}</small>
|
||||
</div>
|
||||
<button type="button" class="row-action" onclick={openEditor}>EDIT SHARED POOL →</button>
|
||||
</div>
|
||||
<div class="mod-secondary-capacity">
|
||||
{#each MOD_QUALITIES as quality}
|
||||
<span><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><strong>{MOD_SECONDARY_CAPACITY[quality]}</strong><small>SECONDARY {MOD_SECONDARY_CAPACITY[quality] === 1 ? 'ROLL' : 'ROLLS'}</small></span>
|
||||
{/each}
|
||||
<span class="pool-total"><b>ATTRIBUTE POOL</b><strong>{pool.length}</strong><small>SHARED OPTIONS</small></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if editing}
|
||||
<div class="dashboard-editor mod-secondary-pool-editor">
|
||||
<div class="editor-title">
|
||||
<div><p class="eyebrow">SHARED CONFIGURATION</p><h3>{kind.toUpperCase()} SECONDARY ATTRIBUTES</h3></div>
|
||||
<button aria-label={`Close ${kind} secondary pool editor`} onclick={() => editing = false}>×</button>
|
||||
</div>
|
||||
<PendingForm {action}>
|
||||
<input type="hidden" name="id" value={poolId}/>
|
||||
<input type="hidden" name="name" value={`Shared ${kind} Secondary Pool`}/>
|
||||
<input type="hidden" name="secondaryRanges" value={JSON.stringify(draft)}/>
|
||||
<p class="console-note">Standard quality has no secondary roll. Specialized selects one option; Superior and High-end select two distinct options. Ranges scale linearly from Level 1 to Level 20.</p>
|
||||
<div class="selection-head pool-editor-head">
|
||||
<div><span>AVAILABLE ATTRIBUTES</span><small>SPECIALIZED · SUPERIOR · HIGH-END LEVEL ENDPOINTS</small></div>
|
||||
<button type="button" class="row-action" onclick={addSecondary} disabled={draft.length >= 50}>ADD ATTRIBUTE +</button>
|
||||
</div>
|
||||
<div class="mod-secondary-pool-options">
|
||||
{#each draft as range, index}
|
||||
<div class="mod-secondary-pool-option">
|
||||
<strong>{String(index + 1).padStart(2, '0')}</strong>
|
||||
<SearchableAssignment
|
||||
items={stats}
|
||||
mode="single"
|
||||
compact
|
||||
subjectLabel="POSSIBLE ATTRIBUTE"
|
||||
searchPlaceholder="Search attributes…"
|
||||
excludedIds={draft.filter((_, itemIndex) => itemIndex !== index).map((item) => item.statId)}
|
||||
bind:value={range.statId}
|
||||
/>
|
||||
{#each MOD_SECONDARY_QUALITIES as quality}
|
||||
<div class="mod-secondary-quality-range">
|
||||
<b class={qualityClass(quality)}>{quality.toUpperCase()}</b>
|
||||
<label><span>LEVEL 1</span><div><input type="number" step="any" bind:value={range.values[quality].level1Min} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 1 minimum`} required/><i>–</i><input type="number" step="any" bind:value={range.values[quality].level1Max} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 1 maximum`} required/></div></label>
|
||||
<label><span>LEVEL 20</span><div><input type="number" step="any" bind:value={range.values[quality].level20Min} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 20 minimum`} required/><i>–</i><input type="number" step="any" bind:value={range.values[quality].level20Max} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 20 maximum`} required/></div></label>
|
||||
</div>
|
||||
{/each}
|
||||
<button type="button" aria-label={`Remove shared secondary attribute option ${index + 1}`} onclick={() => removeSecondary(index)} disabled={draft.length <= 2}>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => editing = false}>CANCEL</button><button class="primary">SAVE SHARED POOL →</button></div>
|
||||
</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -29,8 +29,6 @@
|
||||
gear_max: 'gearMax',
|
||||
weapon_min: 'weaponMin',
|
||||
weapon_max: 'weaponMax',
|
||||
skill_chip_min: 'chipMin',
|
||||
skill_chip_max: 'chipMax',
|
||||
cap_value: 'capValue'
|
||||
};
|
||||
return proposed[names[key] ?? key];
|
||||
@@ -46,6 +44,8 @@
|
||||
);
|
||||
const specializationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
|
||||
const skillChipRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
|
||||
const currentSharedSkillChipProfiles = $derived((Array.isArray(currentSnapshot) ? currentSnapshot : []) as any[]);
|
||||
const proposedSharedSkillChipProfiles = $derived((jsonValue(proposed.profiles) ?? []) as any[]);
|
||||
const skillChipFields = $derived([
|
||||
{ label: 'NAME', current: skillChipRecord?.name, proposed: proposed.name },
|
||||
{ label: 'SPECIALIZATION ABILITY', current: skillChipRecord?.specialization_ability_id, proposed: proposed.abilityId },
|
||||
@@ -256,7 +256,7 @@
|
||||
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
|
||||
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('current', field[0]))}</strong></div>
|
||||
{/each}
|
||||
{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
|
||||
{#each [['gear','GEAR'],['weapon','WEAPON']] as source}
|
||||
<div class="preview-field range"><span>{source[1]} RANGE · OPTIONAL</span><div><strong>{shown(attributeValue('current', `${source[0]}_min`))}</strong><i>TO</i><strong>{shown(attributeValue('current', `${source[0]}_max`))}</strong></div></div>
|
||||
{/each}
|
||||
<div class="preview-field"><span>OPTIONAL CAP</span><strong>{shown(attributeValue('current', 'cap_value'))}</strong></div>
|
||||
@@ -273,13 +273,24 @@
|
||||
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
|
||||
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('proposed', field[0]))}</strong></div>
|
||||
{/each}
|
||||
{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
|
||||
{#each [['gear','GEAR'],['weapon','WEAPON']] as source}
|
||||
<div class="preview-field range"><span>{source[1]} RANGE · OPTIONAL</span><div><strong>{shown(attributeValue('proposed', `${source[0]}_min`))}</strong><i>TO</i><strong>{shown(attributeValue('proposed', `${source[0]}_max`))}</strong></div></div>
|
||||
{/each}
|
||||
<div class="preview-field"><span>OPTIONAL CAP</span><strong>{shown(attributeValue('proposed', 'cap_value'))}</strong></div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
{:else if module === 'skillchips' && ['savePrimaryProfile','saveSecondaryPool'].includes(action)}
|
||||
<div class="proposal-compare-grid">
|
||||
<article class="proposal-editor-preview before">
|
||||
<header><span>CURRENT LIVE · SHARED {action === 'savePrimaryProfile' ? 'PRIMARY PROFILE' : 'SECONDARY POOL'}</span><b>BEFORE</b></header>
|
||||
<div class="proposal-structured-list">{#each currentSharedSkillChipProfiles as profile}<p>{profile.statId} · {Object.entries(profile.values ?? {}).map(([quality, range]: [string, any]) => `${quality.toUpperCase()} ${shown(range.min)}–${shown(range.max)}`).join(' · ')}</p>{:else}<p>SHARED PROFILE NOT CONFIGURED</p>{/each}</div>
|
||||
</article>
|
||||
<article class="proposal-editor-preview after">
|
||||
<header><span>PROPOSED · SHARED {action === 'savePrimaryProfile' ? 'PRIMARY PROFILE' : 'SECONDARY POOL'}</span><b>AFTER</b></header>
|
||||
<div class="proposal-structured-list">{#each proposedSharedSkillChipProfiles as profile}<p>{profile.statId} · {Object.entries(profile.values ?? {}).map(([quality, range]: [string, any]) => `${quality.toUpperCase()} ${shown(range.min)}–${shown(range.max)}`).join(' · ')}</p>{/each}</div>
|
||||
</article>
|
||||
</div>
|
||||
{:else if module === 'skillchips' && action === 'save'}
|
||||
<div class="proposal-compare-grid">
|
||||
<article class="proposal-editor-preview before">
|
||||
@@ -287,7 +298,7 @@
|
||||
{#if skillChipRecord}
|
||||
<div class="proposal-field-summary">
|
||||
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.current)}</strong></div>{/each}
|
||||
<div><span>ROLL POOLS</span><strong>{(currentSnapshot?.primaryOptions as any[])?.length ?? 0} PRIMARY · {(currentSnapshot?.secondaryOptions as any[])?.length ?? 0} SECONDARY</strong></div>
|
||||
<div><span>3-PIECE TALENT</span><strong>{currentSnapshot?.talent ? 'ASSIGNED' : 'NOT ASSIGNED'}</strong></div>
|
||||
</div>
|
||||
<div class="proposal-structured-list">
|
||||
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
|
||||
@@ -301,7 +312,7 @@
|
||||
<header><span>PROPOSED · SKILL CHIP</span><b>AFTER</b></header>
|
||||
<div class="proposal-field-summary">
|
||||
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.proposed)}</strong></div>{/each}
|
||||
<div><span>ROLL POOLS</span><strong>{Array.isArray(proposed.primaryStatIds) ? proposed.primaryStatIds.length : Number(Boolean(proposed.primaryStatIds))} PRIMARY · {Array.isArray(proposed.secondaryStatIds) ? proposed.secondaryStatIds.length : Number(Boolean(proposed.secondaryStatIds))} SECONDARY</strong></div>
|
||||
<div><span>3-PIECE TALENT</span><strong>{proposed.hasTalent === '1' ? 'ASSIGNED' : 'NOT ASSIGNED'}</strong></div>
|
||||
</div>
|
||||
<div class="proposal-structured-list">
|
||||
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
import {
|
||||
SKILL_CHIP_QUALITIES,
|
||||
SKILL_CHIP_SECONDARY_CAPACITY,
|
||||
SKILL_CHIP_SECONDARY_QUALITIES,
|
||||
type SkillChipProfile
|
||||
} from '$lib/skill-chips/profiles';
|
||||
|
||||
let {
|
||||
primaryProfiles,
|
||||
primaryProfileBase,
|
||||
secondaryPool,
|
||||
stats,
|
||||
primaryError,
|
||||
secondaryError,
|
||||
primarySaved = false,
|
||||
secondarySaved = false,
|
||||
resultToken = ''
|
||||
}: {
|
||||
primaryProfiles: SkillChipProfile[];
|
||||
primaryProfileBase: SkillChipProfile[];
|
||||
secondaryPool: SkillChipProfile[];
|
||||
stats: Array<{ id: string; name: string; unit: string }>;
|
||||
primaryError?: string;
|
||||
secondaryError?: string;
|
||||
primarySaved?: boolean;
|
||||
secondarySaved?: boolean;
|
||||
resultToken?: string;
|
||||
} = $props();
|
||||
|
||||
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
|
||||
const blankValues = (qualities: readonly string[]) => Object.fromEntries(
|
||||
qualities.map((quality) => [quality, { min: '', max: '' }])
|
||||
);
|
||||
const hydrated = (profile: SkillChipProfile, qualities: readonly string[]) => ({
|
||||
statId: profile.statId,
|
||||
values: Object.fromEntries(qualities.map((quality) => [quality, {
|
||||
min: profile.values[quality]?.min ?? '',
|
||||
max: profile.values[quality]?.max ?? ''
|
||||
}]))
|
||||
});
|
||||
const blankSecondary = () => ({ statId: '', values: blankValues(SKILL_CHIP_SECONDARY_QUALITIES) });
|
||||
let primaryEditing = $state(false);
|
||||
let secondaryEditing = $state(false);
|
||||
let primaryDraft = $state<any[]>([]);
|
||||
let secondaryDraft = $state<any[]>([]);
|
||||
let handledPrimaryToken = $state('');
|
||||
let handledSecondaryToken = $state('');
|
||||
|
||||
function openPrimary() {
|
||||
primaryDraft = primaryProfiles.map((profile) => hydrated(profile, SKILL_CHIP_QUALITIES));
|
||||
secondaryEditing = false;
|
||||
primaryEditing = true;
|
||||
}
|
||||
function openSecondary() {
|
||||
const loaded: any[] = secondaryPool.map((profile) =>
|
||||
hydrated(profile, SKILL_CHIP_SECONDARY_QUALITIES)
|
||||
);
|
||||
while (loaded.length < 2) loaded.push(blankSecondary());
|
||||
secondaryDraft = loaded;
|
||||
primaryEditing = false;
|
||||
secondaryEditing = true;
|
||||
}
|
||||
$effect(() => {
|
||||
if (primarySaved && resultToken && resultToken !== handledPrimaryToken) {
|
||||
handledPrimaryToken = resultToken;
|
||||
primaryEditing = false;
|
||||
}
|
||||
if (secondarySaved && resultToken && resultToken !== handledSecondaryToken) {
|
||||
handledSecondaryToken = resultToken;
|
||||
secondaryEditing = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="skill-chip-shared-config">
|
||||
<section class="mod-secondary-pool-registry">
|
||||
<div class="selection-head"><div><span>SHARED PRIMARY PROFILE</span><small>FOUR ABILITY-SCOPED STATS · ONE RANGE PER QUALITY</small></div><button type="button" class="row-action" onclick={openPrimary}>EDIT PRIMARY RANGES →</button></div>
|
||||
<div class="mod-secondary-capacity">
|
||||
{#each SKILL_CHIP_QUALITIES as quality}<span><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><strong>{primaryProfiles.filter((profile) => profile.values[quality]).length}</strong><small>OF 4 CONFIGURED</small></span>{/each}
|
||||
<span class="pool-total"><b>CALCULATION</b><strong>—</strong><small>NOT YET DEFINED</small></span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="mod-secondary-pool-registry">
|
||||
<div class="selection-head"><div><span>SHARED SECONDARY POOL</span><small>ONE CANONICAL ATTRIBUTE POOL FOR EVERY SKILL CHIP</small></div><button type="button" class="row-action" onclick={openSecondary}>EDIT SHARED POOL →</button></div>
|
||||
<div class="mod-secondary-capacity">
|
||||
{#each SKILL_CHIP_QUALITIES as quality}<span><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><strong>{SKILL_CHIP_SECONDARY_CAPACITY[quality]}</strong><small>SECONDARY {SKILL_CHIP_SECONDARY_CAPACITY[quality] === 1 ? 'ROLL' : 'ROLLS'}</small></span>{/each}
|
||||
<span class="pool-total"><b>ATTRIBUTE POOL</b><strong>{secondaryPool.length}</strong><small>SHARED OPTIONS</small></span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if primaryEditing}
|
||||
<div class="dashboard-editor skill-chip-profile-editor">
|
||||
<div class="editor-title"><div><p class="eyebrow">SHARED CONFIGURATION</p><h3>SKILL CHIP PRIMARY RANGES</h3></div><button aria-label="Close primary profile editor" onclick={() => primaryEditing = false}>×</button></div>
|
||||
<PendingForm action="?/savePrimaryProfile">
|
||||
<input type="hidden" name="id" value="skill-chip-primary-profile"/><input type="hidden" name="name" value="Shared Skill Chip Primary Profile"/><input type="hidden" name="baseProfiles" value={JSON.stringify(primaryProfileBase)}/><input type="hidden" name="profiles" value={JSON.stringify(primaryDraft)}/>
|
||||
<p class="console-note">Every Skill Chip rolls one of these four ability-scoped primary stats. Configure the direct minimum and maximum for each quality. Current combat calculations do not apply these values.</p>
|
||||
<div class="skill-chip-profile-options fixed">
|
||||
{#each primaryDraft as profile}
|
||||
<div class="skill-chip-profile-option"><div><strong>{stats.find((item) => item.id === profile.statId)?.name ?? 'UNKNOWN ATTRIBUTE'}</strong><small>{stats.find((item) => item.id === profile.statId)?.unit || 'FLAT'} · ABILITY SCOPED</small></div>{#each SKILL_CHIP_QUALITIES as quality}<label><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><span><input type="number" min="0" step="any" bind:value={profile.values[quality].min} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? 'attribute'} minimum`} required/><i>–</i><input type="number" min="0" step="any" bind:value={profile.values[quality].max} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? 'attribute'} maximum`} required/></span></label>{/each}</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if primaryError}<p class="error">{primaryError}</p>{/if}
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => primaryEditing = false}>CANCEL</button><button class="primary">SAVE PRIMARY PROFILE →</button></div>
|
||||
</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if secondaryEditing}
|
||||
<div class="dashboard-editor skill-chip-profile-editor">
|
||||
<div class="editor-title"><div><p class="eyebrow">SHARED CONFIGURATION</p><h3>SKILL CHIP SECONDARY POOL</h3></div><button aria-label="Close secondary pool editor" onclick={() => secondaryEditing = false}>×</button></div>
|
||||
<PendingForm action="?/saveSecondaryPool">
|
||||
<input type="hidden" name="id" value="skill-chip-secondary-pool"/><input type="hidden" name="name" value="Shared Skill Chip Secondary Pool"/><input type="hidden" name="baseProfiles" value={JSON.stringify(secondaryPool)}/><input type="hidden" name="profiles" value={JSON.stringify(secondaryDraft)}/>
|
||||
<p class="console-note">Standard has no secondary roll. Specialized selects one option; Superior and High-end select two distinct options. Skill Chips have no item level, so each quality uses one direct range.</p>
|
||||
<div class="selection-head pool-editor-head"><div><span>AVAILABLE ATTRIBUTES</span><small>SPECIALIZED · SUPERIOR · HIGH-END RANGES</small></div><button type="button" class="row-action" onclick={() => secondaryDraft = [...secondaryDraft, blankSecondary()]} disabled={secondaryDraft.length >= 50}>ADD ATTRIBUTE +</button></div>
|
||||
<div class="skill-chip-profile-options">
|
||||
{#each secondaryDraft as profile, index}
|
||||
<div class="skill-chip-profile-option selectable"><strong>{String(index + 1).padStart(2, '0')}</strong><SearchableAssignment items={stats} mode="single" compact subjectLabel="POSSIBLE ATTRIBUTE" searchPlaceholder="Search attributes…" excludedIds={secondaryDraft.filter((_, itemIndex) => itemIndex !== index).map((item) => item.statId)} bind:value={profile.statId}/>{#each SKILL_CHIP_SECONDARY_QUALITIES as quality}<label><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><span><input type="number" min="0" step="any" bind:value={profile.values[quality].min} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? `option ${index + 1}`} minimum`} required/><i>–</i><input type="number" min="0" step="any" bind:value={profile.values[quality].max} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? `option ${index + 1}`} maximum`} required/></span></label>{/each}<button type="button" aria-label={`Remove secondary option ${index + 1}`} onclick={() => secondaryDraft = secondaryDraft.filter((_, itemIndex) => itemIndex !== index)} disabled={secondaryDraft.length <= 2}>×</button></div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if secondaryError}<p class="error">{secondaryError}</p>{/if}
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => secondaryEditing = false}>CANCEL</button><button class="primary">SAVE SHARED POOL →</button></div>
|
||||
</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
slot,
|
||||
iconUrl,
|
||||
decorative = true,
|
||||
size = 24
|
||||
}: { slot: string; iconUrl?: string | null; decorative?: boolean; size?: number } = $props();
|
||||
</script>
|
||||
|
||||
{#if iconUrl}
|
||||
<img
|
||||
class="weapon-mod-slot-icon"
|
||||
src={iconUrl}
|
||||
alt={decorative ? '' : `${slot} Weapon Mod slot`}
|
||||
loading="lazy"
|
||||
width={size}
|
||||
height={size}
|
||||
style={`--weapon-mod-slot-icon-size: ${size}px`}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
img {
|
||||
width: var(--weapon-mod-slot-icon-size);
|
||||
height: var(--weapon-mod-slot-icon-size);
|
||||
flex: 0 0 var(--weapon-mod-slot-icon-size);
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,212 @@
|
||||
<script lang="ts">
|
||||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import WeaponModSlotIcon from '$lib/components/WeaponModSlotIcon.svelte';
|
||||
|
||||
type SlotIcon = {
|
||||
id: string;
|
||||
name: string;
|
||||
iconUrl: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
let {
|
||||
slots,
|
||||
error = '',
|
||||
saved = false,
|
||||
resultToken = ''
|
||||
}: {
|
||||
slots: SlotIcon[];
|
||||
error?: string;
|
||||
saved?: boolean;
|
||||
resultToken?: string;
|
||||
} = $props();
|
||||
|
||||
let editing = $state<SlotIcon | null>(null);
|
||||
let handledResultToken = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (saved && resultToken && resultToken !== handledResultToken) {
|
||||
handledResultToken = resultToken;
|
||||
editing = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="slot-icon-registry" aria-labelledby="slot-icon-registry-title">
|
||||
<div class="selection-head">
|
||||
<div>
|
||||
<span id="slot-icon-registry-title">SHARED MOD SLOT ICONS</span>
|
||||
<small>ONE CANONICAL ICON FOR EACH WEAPON MOD SLOT</small>
|
||||
</div>
|
||||
<b>{slots.every((slot) => slot.iconUrl) ? 'READY' : 'CONFIG REQUIRED'}</b>
|
||||
</div>
|
||||
<div class="slot-icon-grid">
|
||||
{#each slots as slot}
|
||||
<button
|
||||
type="button"
|
||||
class:configured={Boolean(slot.iconUrl)}
|
||||
aria-pressed={editing?.id === slot.id}
|
||||
onclick={() => editing = slot}
|
||||
>
|
||||
<span class="slot-icon-preview">
|
||||
{#if slot.iconUrl}
|
||||
<WeaponModSlotIcon slot={slot.name} iconUrl={slot.iconUrl}/>
|
||||
{:else}
|
||||
<i aria-hidden="true">?</i>
|
||||
{/if}
|
||||
</span>
|
||||
<span><strong>{slot.name}</strong><small>{slot.iconUrl ? 'ICON REGISTERED' : 'ICON REQUIRED'}</small></span>
|
||||
<b>EDIT</b>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if editing}
|
||||
<section class="dashboard-editor slot-icon-editor" aria-labelledby="slot-icon-editor-title">
|
||||
<div class="editor-title">
|
||||
<div><p class="eyebrow">SHARED MOD SLOT ARTWORK</p><h3 id="slot-icon-editor-title">{editing.name.toUpperCase()}</h3></div>
|
||||
<button aria-label="Close slot icon editor" onclick={() => editing = null}>×</button>
|
||||
</div>
|
||||
<PendingForm action="?/saveSlotIcon" enctype="multipart/form-data">
|
||||
<input type="hidden" name="id" value={editing.id}/>
|
||||
<input type="hidden" name="name" value={`${editing.name} Slot`}/>
|
||||
<input type="hidden" name="slotType" value={editing.name}/>
|
||||
<input type="hidden" name="baseUpdatedAt" value={editing.updatedAt}/>
|
||||
<div class="slot-icon-editor-body">
|
||||
<div class="slot-icon-editor-identity">
|
||||
<span class="slot-icon-preview large">
|
||||
{#if editing.iconUrl}
|
||||
<WeaponModSlotIcon slot={editing.name} iconUrl={editing.iconUrl} size={56}/>
|
||||
{:else}
|
||||
<i aria-hidden="true">?</i>
|
||||
{/if}
|
||||
</span>
|
||||
<div><small>CANONICAL SLOT</small><strong>{editing.name}</strong><p>Weapon cards use this artwork anywhere the slot is available.</p></div>
|
||||
</div>
|
||||
<IconUploadField currentUrl={editing.iconUrl} label={`${editing.name.toUpperCase()} SLOT ICON`} resetToken={`${editing.id}:${editing.updatedAt}`}/>
|
||||
</div>
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE SLOT ICON →</button></div>
|
||||
</PendingForm>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.slot-icon-registry {
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--shd-border);
|
||||
border-left: 2px solid var(--shd-info);
|
||||
background: #0d1011;
|
||||
}
|
||||
.slot-icon-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.slot-icon-grid > button {
|
||||
min-height: 62px;
|
||||
display: grid;
|
||||
grid-template-columns: 38px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--shd-border);
|
||||
background: #101415;
|
||||
color: var(--shd-text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.slot-icon-grid > button:hover,
|
||||
.slot-icon-grid > button:focus-visible,
|
||||
.slot-icon-grid > button[aria-pressed='true'] {
|
||||
border-color: var(--shd-orange);
|
||||
background: #191712;
|
||||
}
|
||||
.slot-icon-grid > button:focus-visible {
|
||||
outline: 2px solid var(--shd-orange);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.slot-icon-grid > button > span:nth-child(2) {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
.slot-icon-grid strong,
|
||||
.slot-icon-grid small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.slot-icon-grid strong {
|
||||
font-size: var(--shd-type-label);
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.slot-icon-grid small,
|
||||
.slot-icon-grid > button > b {
|
||||
color: var(--shd-text-muted);
|
||||
font-size: var(--shd-type-micro);
|
||||
letter-spacing: .07em;
|
||||
}
|
||||
.slot-icon-grid > button.configured small,
|
||||
.slot-icon-grid > button.configured > b {
|
||||
color: var(--shd-info);
|
||||
}
|
||||
.slot-icon-preview {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid #313839;
|
||||
background: #090b0c;
|
||||
}
|
||||
.slot-icon-preview i {
|
||||
color: #586164;
|
||||
font-size: 20px;
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
}
|
||||
.slot-icon-editor {
|
||||
border-top-color: var(--shd-info);
|
||||
}
|
||||
.slot-icon-editor-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, .7fr) minmax(360px, 1.3fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.slot-icon-editor-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--shd-border);
|
||||
background: #0d1011;
|
||||
}
|
||||
.slot-icon-preview.large {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
flex: 0 0 76px;
|
||||
}
|
||||
.slot-icon-editor-identity > div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
.slot-icon-editor-identity small,
|
||||
.slot-icon-editor-identity p {
|
||||
color: var(--shd-text-muted);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
}
|
||||
.slot-icon-editor-identity p {
|
||||
margin: 0;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.slot-icon-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.slot-icon-editor-body { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.slot-icon-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -9,6 +9,8 @@
|
||||
blankConditionalMechanicDraft,
|
||||
currentHealthPercentBandPredicate,
|
||||
eventFieldComparisonPredicate,
|
||||
mechanicTargetingForMode,
|
||||
mechanicTargetingMode,
|
||||
parseConditionalMechanicDraft,
|
||||
readCurrentHealthPercentBand,
|
||||
readEventFieldComparison,
|
||||
@@ -21,6 +23,7 @@
|
||||
type ConditionalMechanicPoolDraft,
|
||||
type EditablePredicateScalar,
|
||||
type EventFieldComparison,
|
||||
type MechanicTargetingMode,
|
||||
type PredicateComparisonOperator,
|
||||
type StatePoolThresholdComparison
|
||||
} from '$lib/calculations/mechanic-editor';
|
||||
@@ -182,7 +185,9 @@
|
||||
}
|
||||
|
||||
function eventPredicateOperatorOptions(clause: ConditionalMechanicClauseDraft) {
|
||||
return selectedEventPredicateField(clause)?.type === 'number'
|
||||
const field = selectedEventPredicateField(clause);
|
||||
if (field?.path === 'values.aiming') return predicateOperatorOptions.filter((option) => option.value === 'eq');
|
||||
return field?.type === 'number'
|
||||
? predicateOperatorOptions
|
||||
: predicateOperatorOptions.filter((option) => option.value === 'eq' || option.value === 'ne');
|
||||
}
|
||||
@@ -190,15 +195,21 @@
|
||||
function setClauseEvent(clauseIndex: number, event: string) {
|
||||
const nextEvent = event as ConditionalMechanicClauseDraft['event'];
|
||||
const clause = rule.clauses[clauseIndex];
|
||||
const outcomes = nextEvent === 'weapon_hit'
|
||||
? clause.outcomes
|
||||
: clause.outcomes.map((outcome) => outcome.targeting?.kind === 'event_target_area'
|
||||
? { ...outcome, targeting: undefined }
|
||||
: outcome);
|
||||
const fields = registeredEventFields(nextEvent);
|
||||
const comparison = readEventFieldComparison(clause.if);
|
||||
if (!comparison || !fields.length) {
|
||||
updateClause(clauseIndex, { event: nextEvent });
|
||||
updateClause(clauseIndex, { event: nextEvent, outcomes });
|
||||
return;
|
||||
}
|
||||
const field = fields[0];
|
||||
updateClause(clauseIndex, {
|
||||
event: nextEvent,
|
||||
outcomes,
|
||||
if: eventFieldComparisonPredicate({
|
||||
path: field.path,
|
||||
operator: 'eq',
|
||||
@@ -403,6 +414,69 @@
|
||||
updateClause(clauseIndex, { outcomes });
|
||||
}
|
||||
|
||||
function targetingOptions(clause: ConditionalMechanicClauseDraft, outcome: ConditionalMechanicOutcomeDraft) {
|
||||
if (outcome.scope === 'allies' || outcome.scope === 'self_and_allies') {
|
||||
return [
|
||||
{ value: 'none', label: 'NONE' },
|
||||
{ value: 'allies_within_radius', label: 'ALLIES IN RADIUS' }
|
||||
];
|
||||
}
|
||||
if (outcome.scope === 'target') {
|
||||
return [
|
||||
{ value: 'none', label: 'NONE' },
|
||||
{ value: 'enemies_within_radius', label: 'ENEMIES IN RADIUS · LEGACY' },
|
||||
...(outcome.kind === 'direct_damage' && clause.event === 'weapon_hit'
|
||||
? [{ value: 'event_target_explosion', label: 'HIT-TARGET EXPLOSION' }]
|
||||
: [])
|
||||
];
|
||||
}
|
||||
return [{ value: 'none', label: 'NONE' }];
|
||||
}
|
||||
|
||||
function setOutcomeScope(clauseIndex: number, outcomeIndex: number, scope: MechanicOutcomeScope) {
|
||||
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
||||
const targeting = outcome.targeting;
|
||||
const compatible = !targeting
|
||||
|| (targeting.kind === 'event_target_area' && scope === 'target' && outcome.kind === 'direct_damage')
|
||||
|| (targeting.kind === 'within_radius' && targeting.population === 'enemies' && scope === 'target')
|
||||
|| (targeting.kind === 'within_radius' && targeting.population === 'allies' && (scope === 'allies' || scope === 'self_and_allies'));
|
||||
updateOutcome(clauseIndex, outcomeIndex, { scope, targeting: compatible ? targeting : undefined });
|
||||
}
|
||||
|
||||
function setOutcomeKind(clauseIndex: number, outcomeIndex: number, kind: ConditionalMechanicOutcomeDraft['kind']) {
|
||||
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
||||
updateOutcome(clauseIndex, outcomeIndex, {
|
||||
kind,
|
||||
resource: kind === 'restoration' || kind === 'resource_cost' ? outcome.resource ?? 'health' : undefined,
|
||||
attributeId: kind === 'attribute_modifier' ? outcome.attributeId ?? '' : undefined,
|
||||
delivery: kind === 'restoration' ? outcome.delivery : undefined,
|
||||
durationSeconds: kind === 'restoration' ? outcome.durationSeconds : undefined,
|
||||
targeting: outcome.targeting?.kind === 'event_target_area' && kind !== 'direct_damage'
|
||||
? undefined
|
||||
: outcome.targeting
|
||||
});
|
||||
}
|
||||
|
||||
function setOutcomeTargeting(clauseIndex: number, outcomeIndex: number, mode: MechanicTargetingMode) {
|
||||
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
||||
updateOutcome(clauseIndex, outcomeIndex, {
|
||||
targeting: mechanicTargetingForMode(mode, outcome.targeting),
|
||||
phase: mode === 'event_target_explosion' && outcome.phase !== 'on_event' && outcome.phase !== 'after_event'
|
||||
? 'after_event'
|
||||
: outcome.phase
|
||||
});
|
||||
}
|
||||
|
||||
function phaseOptions(outcome: ConditionalMechanicOutcomeDraft) {
|
||||
return [
|
||||
{ value: 'on_event', label: 'ON EVENT' },
|
||||
{ value: 'after_event', label: 'AFTER EVENT' },
|
||||
{ value: 'while_active', label: 'WHILE ACTIVE', disabled: outcome.targeting?.kind === 'event_target_area' },
|
||||
{ value: 'on_enter', label: 'ON ENTER', disabled: outcome.targeting?.kind === 'event_target_area' },
|
||||
{ value: 'on_exit', label: 'ON EXIT', disabled: outcome.targeting?.kind === 'event_target_area' }
|
||||
];
|
||||
}
|
||||
|
||||
function updateVariant(clauseIndex: number, outcomeIndex: number, variant: string, raw: string) {
|
||||
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
||||
const value = Number(raw);
|
||||
@@ -684,6 +758,50 @@
|
||||
});
|
||||
}
|
||||
|
||||
function addAbilityDurationExtension(clauseIndex: number) {
|
||||
updateClause(clauseIndex, {
|
||||
mutations: [
|
||||
...rule.clauses[clauseIndex].mutations,
|
||||
{ kind: 'extend_duration', target: 'assigned_ability', seconds: 0.3 }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
function addCooldownOnMax(clauseIndex: number) {
|
||||
const stackPool = rule.pools.find((pool) => pool.max && pool.max > 0)?.key;
|
||||
const cooldownPool = rule.pools.find((pool) => pool.scope === 'agent' && pool.expiresAfterSeconds)?.key;
|
||||
if (!stackPool || !cooldownPool || stackPool === cooldownPool) return;
|
||||
updateClause(clauseIndex, {
|
||||
mutations: [
|
||||
...rule.clauses[clauseIndex].mutations,
|
||||
{ kind: 'start_cooldown_on_max', stackPool, cooldownPool }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
function updateAbilityDurationExtension(clauseIndex: number, mutationIndex: number, raw: string) {
|
||||
const mutations = [...rule.clauses[clauseIndex].mutations];
|
||||
const seconds = Number(raw);
|
||||
mutations[mutationIndex] = {
|
||||
kind: 'extend_duration',
|
||||
target: 'assigned_ability',
|
||||
seconds: Number.isFinite(seconds) ? seconds : 0
|
||||
};
|
||||
updateClause(clauseIndex, { mutations });
|
||||
}
|
||||
|
||||
function updateCooldownOnMax(
|
||||
clauseIndex: number,
|
||||
mutationIndex: number,
|
||||
patch: { stackPool?: string; cooldownPool?: string }
|
||||
) {
|
||||
const mutations = [...rule.clauses[clauseIndex].mutations];
|
||||
const current = mutations[mutationIndex];
|
||||
if (current.kind !== 'start_cooldown_on_max') return;
|
||||
mutations[mutationIndex] = { ...current, ...patch };
|
||||
updateClause(clauseIndex, { mutations });
|
||||
}
|
||||
|
||||
function updateMutation(clauseIndex: number, mutationIndex: number, kind: 'add' | 'set' | 'consume' | 'clear', pool?: string, value?: number) {
|
||||
const mutations = [...rule.clauses[clauseIndex].mutations];
|
||||
const current = mutations[mutationIndex];
|
||||
@@ -981,6 +1099,20 @@
|
||||
{:else if conditionModel(clause) === 'advanced'}
|
||||
<p class="advanced-condition-note">This saved condition remains active and is preserved unchanged. Use the generated mechanic preview to inspect it.</p>
|
||||
{/if}
|
||||
{#if allowAssignedAbility}
|
||||
<div class="ability-proc-controls">
|
||||
<SegmentedChoice
|
||||
legend="ASSIGNED ABILITY"
|
||||
name={`${idPrefix}-assigned-ability-${clauseIndex}`}
|
||||
options={[{value:'false',label:'ANY STATE'},{value:'true',label:'MUST BE ACTIVE'}]}
|
||||
value={String(clause.requiresAssignedAbilityActive ?? false)}
|
||||
onchange={(values) => updateClause(clauseIndex,{requiresAssignedAbilityActive:values[0] === 'true' ? true : undefined})}
|
||||
/>
|
||||
<label class="field">PROC CHANCE · %<input type="number" min="0" max="100" step="any" value={clause.procChancePercent ?? ''} oninput={(event) => updateClause(clauseIndex,{procChancePercent:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/><small>Rolls after conditions and cooldown checks, before mutations.</small></label>
|
||||
</div>
|
||||
{:else}
|
||||
<label class="field proc-chance-field">PROC CHANCE · %<input type="number" min="0" max="100" step="any" value={clause.procChancePercent ?? ''} oninput={(event) => updateClause(clauseIndex,{procChancePercent:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/><small>Rolls after conditions and cooldown checks, before mutations.</small></label>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="reducer-controls">
|
||||
<SegmentedChoice
|
||||
@@ -1007,8 +1139,22 @@
|
||||
</div>
|
||||
|
||||
<div class="mutation-panel">
|
||||
<div class="outcome-heading"><div><span>STATE CHANGES</span><strong>{clause.mutations.length}</strong></div><button type="button" class="secondary compact-action" onclick={() => addMutation(clauseIndex)} disabled={!rule.pools.length}>ADD STATE CHANGE +</button></div>
|
||||
<div class="outcome-heading"><div><span>STATE CHANGES</span><strong>{clause.mutations.length}</strong></div><div class="mutation-actions"><button type="button" class="secondary compact-action" onclick={() => addMutation(clauseIndex)} disabled={!rule.pools.length}>ADD STATE CHANGE +</button>{#if allowAssignedAbility}<button type="button" class="secondary compact-action" onclick={() => addAbilityDurationExtension(clauseIndex)}>EXTEND ABILITY +</button>{/if}<button type="button" class="secondary compact-action" onclick={() => addCooldownOnMax(clauseIndex)} disabled={rule.pools.filter((pool) => pool.max && pool.max > 0).length < 2}>ADD MAX COOLDOWN +</button></div></div>
|
||||
{#each clause.mutations as mutation, mutationIndex}
|
||||
{#if mutation.kind === 'extend_duration'}
|
||||
<div class="mutation-row ability-mutation-row">
|
||||
<div class="branch-readout"><span>OPERATION</span><strong>EXTEND ASSIGNED ABILITY</strong></div>
|
||||
<label class="field">SECONDS<input type="number" min="0" step="any" value={typeof mutation.seconds === 'number' ? mutation.seconds : 0} oninput={(event) => updateAbilityDurationExtension(clauseIndex,mutationIndex,event.currentTarget.value)}/><small>Applies only while the assigned ability remains active.</small></label>
|
||||
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
|
||||
</div>
|
||||
{:else if mutation.kind === 'start_cooldown_on_max'}
|
||||
<div class="mutation-row cooldown-mutation-row">
|
||||
<div class="branch-readout"><span>OPERATION</span><strong>START COOLDOWN ON MAX</strong></div>
|
||||
<SegmentedChoice legend="STACK POOL" name={`${idPrefix}-cooldown-stack-${clauseIndex}-${mutationIndex}`} options={rule.pools.filter((pool) => pool.max && pool.max > 0).map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))} value={mutation.stackPool} onchange={(values) => updateCooldownOnMax(clauseIndex,mutationIndex,{stackPool:values[0]})}/>
|
||||
<SegmentedChoice legend="COOLDOWN POOL" name={`${idPrefix}-cooldown-pool-${clauseIndex}-${mutationIndex}`} options={rule.pools.filter((pool) => pool.scope === 'agent' && pool.expiresAfterSeconds && pool.key !== mutation.stackPool).map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))} value={mutation.cooldownPool} onchange={(values) => updateCooldownOnMax(clauseIndex,mutationIndex,{cooldownPool:values[0]})}/>
|
||||
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mutation-row">
|
||||
<SegmentedChoice
|
||||
legend="OPERATION"
|
||||
@@ -1039,6 +1185,7 @@
|
||||
{/if}
|
||||
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="empty-state">This branch emits outcomes without changing shared state.</p>
|
||||
{/each}
|
||||
@@ -1049,25 +1196,24 @@
|
||||
<div class="outcome-card">
|
||||
<div class="outcome-grid">
|
||||
<label class="field">LABEL<input value={outcome.label} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label>
|
||||
<SegmentedChoice legend="KIND" name={`${idPrefix}-kind-${clauseIndex}-${outcomeIndex}`} options={outcomeKinds} value={outcome.kind} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{kind:values[0] as ConditionalMechanicOutcomeDraft['kind'],resource:values[0] === 'restoration' || values[0] === 'resource_cost' ? outcome.resource ?? 'health' : undefined,attributeId:values[0] === 'attribute_modifier' ? outcome.attributeId ?? '' : undefined,delivery:values[0] === 'restoration' ? outcome.delivery : undefined,durationSeconds:values[0] === 'restoration' ? outcome.durationSeconds : undefined})}/>
|
||||
<SegmentedChoice legend="KIND" name={`${idPrefix}-kind-${clauseIndex}-${outcomeIndex}`} options={outcomeKinds} value={outcome.kind} onchange={(values) => setOutcomeKind(clauseIndex,outcomeIndex,values[0] as ConditionalMechanicOutcomeDraft['kind'])}/>
|
||||
<label class="field">DESCRIPTION SLOT<input type="number" min="1" step="1" value={outcome.descriptionSlot ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{descriptionSlot:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
|
||||
</div>
|
||||
<SegmentedChoice legend="TARGET SCOPE" name={`${idPrefix}-scope-${clauseIndex}-${outcomeIndex}`} options={scopeOptions} value={outcome.scope} columns={scopeOptions.length > 4 ? 4 : scopeOptions.length} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{scope:values[0] as MechanicOutcomeScope})}/>
|
||||
<SegmentedChoice legend="TARGET SCOPE" name={`${idPrefix}-scope-${clauseIndex}-${outcomeIndex}`} options={scopeOptions} value={outcome.scope} columns={scopeOptions.length > 4 ? 4 : scopeOptions.length} onchange={(values) => setOutcomeScope(clauseIndex,outcomeIndex,values[0] as MechanicOutcomeScope)}/>
|
||||
{#if outcome.scope === 'allies' || outcome.scope === 'self_and_allies' || outcome.scope === 'target'}
|
||||
<div class="delivery-controls">
|
||||
<SegmentedChoice
|
||||
legend="AREA TARGETING"
|
||||
name={`${idPrefix}-targeting-${clauseIndex}-${outcomeIndex}`}
|
||||
options={[{value:'none',label:'NONE'},{value:'allies',label:'ALLIES IN RADIUS'},{value:'enemies',label:'ENEMIES IN RADIUS'}]}
|
||||
value={outcome.targeting?.population ?? 'none'}
|
||||
onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{
|
||||
targeting: values[0] === 'none'
|
||||
? undefined
|
||||
: {kind:'within_radius',population:values[0] as 'allies'|'enemies',radiusMeters:outcome.targeting?.radiusMeters ?? 20}
|
||||
})}
|
||||
options={targetingOptions(clause,outcome)}
|
||||
value={mechanicTargetingMode(outcome.targeting)}
|
||||
onchange={(values) => setOutcomeTargeting(clauseIndex,outcomeIndex,values[0] as MechanicTargetingMode)}
|
||||
/>
|
||||
{#if outcome.targeting}
|
||||
<label class="field">RADIUS (METERS)<input type="number" min="0.01" step="any" value={outcome.targeting.radiusMeters} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{targeting:{...outcome.targeting!,radiusMeters:Number(event.currentTarget.value)}})}/></label>
|
||||
{#if outcome.targeting.kind === 'event_target_area'}
|
||||
<p class="area-targeting-note"><strong>EVENT TARGET CENTER</strong><span>Damages each valid enemy at or inside the radius once. Runtime events must supply the authoritative target sample.</span></p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1154,8 +1300,9 @@
|
||||
<label class="field">{column.label} VALUE<input type="number" step="any" value={outcome.variants[column.variantKey]?.value ?? 0} oninput={(event) => updateVariant(clauseIndex,outcomeIndex,column.variantKey,event.currentTarget.value)}/></label>
|
||||
{/each}
|
||||
</div>
|
||||
<label class="field probability-field">OUTCOME CHANCE · %<input type="number" min="0" max="100" step="any" value={outcome.probabilityPercent ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{probabilityPercent:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/><small>Each eligible outcome rolls independently. Maximum Potential resolves each nonzero chance as successful.</small></label>
|
||||
<div class="outcome-footer">
|
||||
<SegmentedChoice legend="PHASE" name={`${idPrefix}-phase-${clauseIndex}-${outcomeIndex}`} options={[{value:'on_event',label:'ON EVENT'},{value:'after_event',label:'AFTER EVENT'},{value:'while_active',label:'WHILE ACTIVE'},{value:'on_enter',label:'ON ENTER'},{value:'on_exit',label:'ON EXIT'}]} value={outcome.phase} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{phase:values[0] as OutcomePhase})}/>
|
||||
<SegmentedChoice legend="PHASE" name={`${idPrefix}-phase-${clauseIndex}-${outcomeIndex}`} options={phaseOptions(outcome)} value={outcome.phase} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{phase:values[0] as OutcomePhase})}/>
|
||||
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{outcomes:clause.outcomes.filter((_,index) => index !== outcomeIndex)})}>REMOVE OUTCOME</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1198,6 +1345,8 @@
|
||||
.branch>header>div{display:flex;align-items:end;gap:9px}.branch>header>div>span{align-self:center;color:var(--shd-orange);font:900 var(--shd-type-telemetry)/1 var(--shd-font-mono)}.branch>header .field{min-width:min(360px,55vw);margin:0}
|
||||
.branch-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr)) minmax(160px,.45fr);align-items:end;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)}
|
||||
.condition-controls{display:grid;grid-template-columns:minmax(260px,.7fr) minmax(320px,1.3fr);align-items:end;gap:var(--shd-gap-sm);padding:0 var(--shd-gap-sm) var(--shd-gap-sm)}
|
||||
.ability-proc-controls{display:grid;grid-template-columns:minmax(240px,.7fr) minmax(180px,.4fr);align-items:end;gap:var(--shd-gap-sm)}
|
||||
.ability-proc-controls .field small,.proc-chance-field small,.probability-field small,.ability-mutation-row .field small{display:block;margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.3}
|
||||
.health-boundaries{display:grid;grid-template-columns:repeat(2,minmax(150px,1fr));gap:var(--shd-gap-sm)}
|
||||
.health-boundaries .field small{display:block;margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.3}
|
||||
.predicate-fields{display:grid;grid-template-columns:minmax(170px,1fr) minmax(190px,.8fr);align-items:end;gap:var(--shd-gap-sm)}
|
||||
@@ -1207,12 +1356,15 @@
|
||||
.branch-readout{display:grid;align-content:center;gap:3px;min-height:44px;padding:7px 9px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}.branch-readout span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.branch-readout strong{font-size:var(--shd-type-supporting)}
|
||||
.reducer-controls{display:grid;grid-template-columns:minmax(0,1fr) repeat(2,minmax(130px,.25fr));align-items:end;gap:var(--shd-gap-sm);padding:0 var(--shd-gap-sm) var(--shd-gap-sm)}
|
||||
.mutation-panel{margin:0 var(--shd-gap-sm) var(--shd-gap-sm);border:1px solid var(--shd-border-soft)}
|
||||
.mutation-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}
|
||||
.mutation-row{display:grid;grid-template-columns:minmax(260px,1fr) minmax(220px,1fr) minmax(110px,.35fr) minmax(240px,1fr) auto;align-items:end;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border-soft)}
|
||||
.ability-mutation-row{grid-template-columns:minmax(240px,1fr) minmax(180px,.5fr) auto}.cooldown-mutation-row{grid-template-columns:minmax(210px,.8fr) minmax(220px,1fr) minmax(220px,1fr) auto}.probability-field{max-width:260px}
|
||||
.outcome-card{display:grid;gap:8px;margin:var(--shd-gap-sm);padding:9px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}
|
||||
.outcome-grid{display:grid;grid-template-columns:minmax(200px,1fr) minmax(360px,1.4fr) minmax(120px,.3fr);align-items:end;gap:8px}
|
||||
.variant-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px}
|
||||
.state-scaling{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr) repeat(2,minmax(110px,.3fr));align-items:end;gap:8px}
|
||||
.delivery-controls{display:grid;grid-template-columns:minmax(260px,1fr) minmax(150px,.35fr);align-items:end;gap:8px}.delivery-controls>.field{margin:0}
|
||||
.area-targeting-note{grid-column:1/-1;display:grid;gap:2px;margin:0;padding:7px 9px;border-left:2px solid var(--shd-info);background:var(--shd-model-surface);color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.area-targeting-note strong{color:var(--shd-info);font-size:var(--shd-type-telemetry);letter-spacing:.7px}
|
||||
.outcome-footer{display:flex;align-items:end;justify-content:space-between;gap:8px}
|
||||
.remove,.danger-quiet{min-height:38px;padding:0 10px;border:1px solid var(--shd-danger);background:transparent;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}
|
||||
.remove:hover,.remove:focus-visible,.danger-quiet:hover,.danger-quiet:focus-visible{background:var(--shd-danger);color:var(--shd-surface)}
|
||||
|
||||
@@ -3,6 +3,11 @@ import type { GameDataTargetType } from '$lib/game-data-targets';
|
||||
|
||||
export type DatabaseBadge = { label: string; tone?: string };
|
||||
export type DatabaseMeter = { label: string; value: number };
|
||||
export type DatabaseWeaponModSlot = {
|
||||
slot: string;
|
||||
unlockedAtTierOne: boolean;
|
||||
iconUrl?: string;
|
||||
};
|
||||
export type DatabaseFact = {
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -26,8 +31,10 @@ export type DatabaseItem = {
|
||||
renderUrl?: string;
|
||||
gameIcon?: GameIconName;
|
||||
headerBadge?: string;
|
||||
headerBadgeTone?: string;
|
||||
headerBadges?: string[];
|
||||
headerMetaIcon?: GameIconName;
|
||||
topBorderTone?: string;
|
||||
badges?: DatabaseBadge[];
|
||||
facts: DatabaseFact[];
|
||||
factLayout?: 'grid' | 'stack';
|
||||
@@ -35,9 +42,17 @@ export type DatabaseItem = {
|
||||
lines?: DatabaseFact[];
|
||||
afterLines?: DatabaseFact[];
|
||||
iconGroups?: DatabaseIconGroup[];
|
||||
weaponModSlots?: DatabaseWeaponModSlot[];
|
||||
description?: string;
|
||||
secondaryDescription?: string;
|
||||
descriptionStyle?: 'prose' | 'data';
|
||||
protocolPreview?: {
|
||||
primaryStat: string;
|
||||
primaryUnit: string;
|
||||
primaryBaseValue: number;
|
||||
talentDescription?: string;
|
||||
talentEffects: Array<{ baseValue: number; unit: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
export type DatabaseRelatedItem = {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
MOD_SECONDARY_CAPACITY,
|
||||
normalizeModSecondaryPool,
|
||||
} from './secondary-pool';
|
||||
|
||||
const endpoints = (
|
||||
level1Min: number | string,
|
||||
level1Max: number | string,
|
||||
level20Min: number | string,
|
||||
level20Max: number | string,
|
||||
) => ({ level1Min, level1Max, level20Min, level20Max });
|
||||
|
||||
const range = (statId: string) => ({
|
||||
statId,
|
||||
values: {
|
||||
Specialized: endpoints('1.5', '2.5', '3.5', '4.5'),
|
||||
Superior: endpoints(2, 3, 4, 5),
|
||||
'High-end': endpoints(3, 4, 5, 6),
|
||||
},
|
||||
});
|
||||
|
||||
describe('shared Mod secondary pool', () => {
|
||||
it('defines secondary capacity for every Mod quality', () => {
|
||||
expect(MOD_SECONDARY_CAPACITY).toEqual({
|
||||
Standard: 0,
|
||||
Specialized: 1,
|
||||
Superior: 2,
|
||||
'High-end': 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes Attribute IDs and valid Level 1/20 endpoints', () => {
|
||||
const result = normalizeModSecondaryPool([
|
||||
range(' critical-hit-chance '),
|
||||
range('critical-hit-damage'),
|
||||
]);
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.pool).toEqual([
|
||||
{
|
||||
statId: 'critical-hit-chance',
|
||||
values: {
|
||||
Specialized: endpoints(1.5, 2.5, 3.5, 4.5),
|
||||
Superior: endpoints(2, 3, 4, 5),
|
||||
'High-end': endpoints(3, 4, 5, 6),
|
||||
},
|
||||
},
|
||||
{
|
||||
statId: 'critical-hit-damage',
|
||||
values: {
|
||||
Specialized: endpoints(1.5, 2.5, 3.5, 4.5),
|
||||
Superior: endpoints(2, 3, 4, 5),
|
||||
'High-end': endpoints(3, 4, 5, 6),
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects duplicate Attributes after ID normalization', () => {
|
||||
const result = normalizeModSecondaryPool([
|
||||
range('critical-hit-chance'),
|
||||
range(' critical-hit-chance '),
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
pool: [],
|
||||
error: 'Choose a unique Attribute for every shared pool option.',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'missing quality ranges',
|
||||
{
|
||||
...range('critical-hit-chance'),
|
||||
values: {
|
||||
Specialized: endpoints(1, 2, 3, 4),
|
||||
Superior: endpoints(2, 3, 4, 5),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'reversed ranges',
|
||||
{
|
||||
...range('critical-hit-chance'),
|
||||
values: {
|
||||
...range('critical-hit-chance').values,
|
||||
Specialized: endpoints(2, 1, 3, 4),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'Level 20 regression',
|
||||
{
|
||||
...range('critical-hit-chance'),
|
||||
values: {
|
||||
...range('critical-hit-chance').values,
|
||||
Specialized: endpoints(2, 3, 1, 2),
|
||||
},
|
||||
},
|
||||
],
|
||||
])('rejects %s', (_label, invalidRange) => {
|
||||
const result = normalizeModSecondaryPool([
|
||||
invalidRange,
|
||||
range('critical-hit-damage'),
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
pool: [],
|
||||
error:
|
||||
'Configure valid Level 1 and Level 20 ranges for Specialized, Superior, and High-end quality.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
export const MOD_QUALITIES = [
|
||||
'Standard',
|
||||
'Specialized',
|
||||
'Superior',
|
||||
'High-end',
|
||||
] as const;
|
||||
|
||||
export const MOD_SECONDARY_QUALITIES = [
|
||||
'Specialized',
|
||||
'Superior',
|
||||
'High-end',
|
||||
] as const;
|
||||
|
||||
export const MOD_SECONDARY_CAPACITY = {
|
||||
Standard: 0,
|
||||
Specialized: 1,
|
||||
Superior: 2,
|
||||
'High-end': 2,
|
||||
} as const satisfies Record<(typeof MOD_QUALITIES)[number], number>;
|
||||
|
||||
export type ModSecondaryQuality = (typeof MOD_SECONDARY_QUALITIES)[number];
|
||||
|
||||
export type ModSecondaryRange = {
|
||||
statId: string;
|
||||
values: Record<
|
||||
ModSecondaryQuality,
|
||||
{
|
||||
level1Min: number;
|
||||
level1Max: number;
|
||||
level20Min: number;
|
||||
level20Max: number;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
export function normalizeModSecondaryPool(input: unknown): {
|
||||
pool: ModSecondaryRange[];
|
||||
error: string | null;
|
||||
} {
|
||||
if (!Array.isArray(input) || input.length < 2 || input.length > 50) {
|
||||
return {
|
||||
pool: [],
|
||||
error: 'Configure between two and 50 shared secondary Attributes.',
|
||||
};
|
||||
}
|
||||
|
||||
const pool = input.map((candidate) => {
|
||||
const range = candidate as Partial<ModSecondaryRange>;
|
||||
return {
|
||||
statId: typeof range.statId === 'string' ? range.statId.trim() : '',
|
||||
values: Object.fromEntries(
|
||||
MOD_SECONDARY_QUALITIES.map((quality) => {
|
||||
const values = range.values?.[quality] as
|
||||
| Partial<ModSecondaryRange['values'][ModSecondaryQuality]>
|
||||
| undefined;
|
||||
return [
|
||||
quality,
|
||||
{
|
||||
level1Min: Number(values?.level1Min),
|
||||
level1Max: Number(values?.level1Max),
|
||||
level20Min: Number(values?.level20Min),
|
||||
level20Max: Number(values?.level20Max),
|
||||
},
|
||||
];
|
||||
}),
|
||||
) as ModSecondaryRange['values'],
|
||||
};
|
||||
});
|
||||
|
||||
if (
|
||||
pool.some((range) => !range.statId) ||
|
||||
new Set(pool.map((range) => range.statId)).size !== pool.length
|
||||
) {
|
||||
return {
|
||||
pool: [],
|
||||
error: 'Choose a unique Attribute for every shared pool option.',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
pool.some((range) =>
|
||||
MOD_SECONDARY_QUALITIES.some((quality) => {
|
||||
const values = range.values[quality];
|
||||
return (
|
||||
Object.values(values).some((value) => !Number.isFinite(value)) ||
|
||||
values.level1Max < values.level1Min ||
|
||||
values.level20Max < values.level20Min ||
|
||||
values.level20Min < values.level1Min ||
|
||||
values.level20Max < values.level1Max
|
||||
);
|
||||
}),
|
||||
)
|
||||
) {
|
||||
return {
|
||||
pool: [],
|
||||
error:
|
||||
'Configure valid Level 1 and Level 20 ranges for Specialized, Superior, and High-end quality.',
|
||||
};
|
||||
}
|
||||
|
||||
return { pool, error: null };
|
||||
}
|
||||
@@ -94,7 +94,7 @@ describe('AI mechanic authoring prompt', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(7);
|
||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
|
||||
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
|
||||
expect.arrayContaining(['delivery', 'durationSeconds']),
|
||||
);
|
||||
@@ -136,7 +136,7 @@ describe('AI mechanic authoring prompt', () => {
|
||||
]),
|
||||
);
|
||||
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
||||
'Prompt contract version: 7',
|
||||
'Prompt contract version: 10',
|
||||
);
|
||||
|
||||
const advertised = structuredClone(
|
||||
@@ -185,7 +185,7 @@ describe('AI mechanic authoring prompt', () => {
|
||||
expect(parsed.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('advertises the canonical Extra Health change event contract in version 7', () => {
|
||||
it('advertises the canonical Extra Health change event contract in version 10', () => {
|
||||
const input = JSON.parse(
|
||||
buildAiMechanicAuthoringInput({
|
||||
request,
|
||||
@@ -196,9 +196,9 @@ describe('AI mechanic authoring prompt', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(7);
|
||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
|
||||
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
||||
'Prompt contract version: 7',
|
||||
'Prompt contract version: 10',
|
||||
);
|
||||
expect(input.canonicalCatalog.events).toContainEqual({
|
||||
key: 'extra_health_changed',
|
||||
@@ -222,6 +222,144 @@ describe('AI mechanic authoring prompt', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('advertises assigned-ability activity for kill and assist predicates', () => {
|
||||
const input = JSON.parse(
|
||||
buildAiMechanicAuthoringInput({
|
||||
request,
|
||||
attributes: [],
|
||||
events: [
|
||||
MECHANIC_EVENT_REGISTRY.enemy_killed,
|
||||
MECHANIC_EVENT_REGISTRY.assist,
|
||||
],
|
||||
actions: [],
|
||||
recipeKeys: [],
|
||||
}),
|
||||
);
|
||||
|
||||
for (const event of input.canonicalCatalog.events) {
|
||||
expect(event.fields).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
path: 'values.assignedAbilityActive',
|
||||
label: 'Assigned ability active',
|
||||
type: 'boolean',
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
expect(input.examplesAndRules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('values.assignedAbilityActive'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('advertises and round-trips aimed event-target area damage', () => {
|
||||
const protocolRequest: AiMechanicAuthoringRequest = {
|
||||
...request,
|
||||
context: {
|
||||
sourceKind: 'os_protocol_talent',
|
||||
sourceName: 'Untitled mechanic',
|
||||
sourceDescription:
|
||||
'Critical Hits performed during aiming create an explosion with a radius of 5 meters around the target that deals damage equal to 2000% Firepower to them.',
|
||||
},
|
||||
valueColumns: [{ key: 'base', variantKey: 'base', label: 'BASE' }],
|
||||
allowProtocolUpgradeScaling: true,
|
||||
};
|
||||
const input = JSON.parse(
|
||||
buildAiMechanicAuthoringInput({
|
||||
request: protocolRequest,
|
||||
attributes: [
|
||||
{
|
||||
id: 'firepower-id',
|
||||
name: 'Firepower',
|
||||
description: null,
|
||||
unit: '',
|
||||
type: null,
|
||||
category: null,
|
||||
calculationType: null,
|
||||
},
|
||||
],
|
||||
events: [MECHANIC_EVENT_REGISTRY.weapon_hit],
|
||||
actions: [],
|
||||
recipeKeys: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(input.canonicalCatalog.events[0].fields).toContainEqual({
|
||||
path: 'values.aiming',
|
||||
label: 'Aiming down sights',
|
||||
type: 'boolean',
|
||||
});
|
||||
expect(
|
||||
input.canonicalCatalog.grammar.outcome.targeting.eventTargetArea,
|
||||
).toMatchObject({
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
});
|
||||
expect(input.examplesAndRules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('values.aiming'),
|
||||
expect.stringContaining('event_target_area'),
|
||||
]),
|
||||
);
|
||||
|
||||
const advertised = structuredClone(
|
||||
input.canonicalCatalog.grammar.aimedCriticalAreaDamageShape,
|
||||
);
|
||||
const outcome = advertised.clauses[0].outcomes[0];
|
||||
const base = JSON.parse(
|
||||
JSON.stringify(
|
||||
outcome.variants['replace-with-each-required-variant-key'],
|
||||
).replaceAll(
|
||||
'replace-with-an-exact-catalog-attribute-id',
|
||||
'firepower-id',
|
||||
),
|
||||
);
|
||||
outcome.variants = { base };
|
||||
const parsed = parseConditionalMechanicDraft(advertised, {
|
||||
variantKeys: ['base'],
|
||||
allowedAttributeIds: new Set(['firepower-id']),
|
||||
});
|
||||
expect(parsed.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('advertises reproducible outcome and clause proc probabilities in version 10', () => {
|
||||
const input = JSON.parse(
|
||||
buildAiMechanicAuthoringInput({
|
||||
request,
|
||||
attributes: [],
|
||||
events: [MECHANIC_EVENT_REGISTRY.skill_hit],
|
||||
actions: [],
|
||||
recipeKeys: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
|
||||
expect(input.canonicalCatalog.grammar.clause.optional).toEqual(
|
||||
expect.arrayContaining([
|
||||
'procChancePercent',
|
||||
'requiresAssignedAbilityActive',
|
||||
]),
|
||||
);
|
||||
expect(input.canonicalCatalog.grammar.mutationKinds).toEqual(
|
||||
expect.arrayContaining(['extend_duration', 'start_cooldown_on_max']),
|
||||
);
|
||||
expect(
|
||||
input.canonicalCatalog.grammar.probabilityOutcomeShape,
|
||||
).toMatchObject({
|
||||
probabilityPercent: 29,
|
||||
resource: 'ammo',
|
||||
});
|
||||
expect(input.examplesAndRules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('procChancePercent'),
|
||||
expect.stringContaining('reproducible seeded random stream'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('advertises authoritative current Health percentage with strict band semantics', () => {
|
||||
const input = JSON.parse(
|
||||
buildAiMechanicAuthoringInput({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES } from '$lib/calculations/mec
|
||||
import { MECHANIC_COMBAT_REFERENCE_REGISTRY } from '$lib/calculations/mechanic';
|
||||
import type { AiMechanicAttribute } from './mechanic-authoring-context';
|
||||
|
||||
export const AI_MECHANIC_PROMPT_VERSION = 7;
|
||||
export const AI_MECHANIC_PROMPT_VERSION = 10;
|
||||
|
||||
const mechanicsGrammar = {
|
||||
version: 1,
|
||||
@@ -40,7 +40,14 @@ const mechanicsGrammar = {
|
||||
},
|
||||
clause: {
|
||||
required: ['key', 'event', 'if', 'reducer', 'mutations', 'outcomes'],
|
||||
optional: ['label', 'recipeKey', 'cooldownSeconds', 'weight'],
|
||||
optional: [
|
||||
'label',
|
||||
'recipeKey',
|
||||
'cooldownSeconds',
|
||||
'weight',
|
||||
'procChancePercent',
|
||||
'requiresAssignedAbilityActive',
|
||||
],
|
||||
},
|
||||
predicateKinds: ['true', 'exists', 'compare', 'all', 'any', 'not'],
|
||||
compareOperators: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'],
|
||||
@@ -63,6 +70,8 @@ const mechanicsGrammar = {
|
||||
'derive',
|
||||
'clear',
|
||||
'transfer',
|
||||
'extend_duration',
|
||||
'start_cooldown_on_max',
|
||||
],
|
||||
entitySelectors: ['agent', 'event_actor', 'event_target', 'event_field'],
|
||||
valueReferences: [
|
||||
@@ -70,6 +79,7 @@ const mechanicsGrammar = {
|
||||
'scenario',
|
||||
'combat',
|
||||
'equipment',
|
||||
'assigned_ability',
|
||||
'attribute',
|
||||
'reducer',
|
||||
'pool',
|
||||
@@ -97,6 +107,7 @@ const mechanicsGrammar = {
|
||||
},
|
||||
},
|
||||
{ ref: { source: 'equipment', path: 'protocolTalentUpgrades' } },
|
||||
{ ref: { source: 'assigned_ability', path: 'remainingDurationSeconds' } },
|
||||
{
|
||||
ref: {
|
||||
source: 'attribute',
|
||||
@@ -200,6 +211,16 @@ const mechanicsGrammar = {
|
||||
value: 'all',
|
||||
clearSource: true,
|
||||
},
|
||||
{
|
||||
kind: 'extend_duration',
|
||||
target: 'assigned_ability',
|
||||
seconds: 0.3,
|
||||
},
|
||||
{
|
||||
kind: 'start_cooldown_on_max',
|
||||
stackPool: 'extension-stacks',
|
||||
cooldownPool: 'extension-cooldown',
|
||||
},
|
||||
],
|
||||
outcome: CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES,
|
||||
continuousRestorationOutcomeShape: {
|
||||
@@ -232,6 +253,83 @@ const mechanicsGrammar = {
|
||||
},
|
||||
},
|
||||
},
|
||||
probabilityOutcomeShape: {
|
||||
key: 'recover-ammo',
|
||||
label: 'Recover Ammo',
|
||||
phase: 'after_event',
|
||||
kind: 'restoration',
|
||||
scope: 'self',
|
||||
basis: 'flat',
|
||||
resource: 'ammo',
|
||||
probabilityPercent: 29,
|
||||
variants: {
|
||||
'replace-with-each-required-variant-key': { value: 1 },
|
||||
},
|
||||
},
|
||||
aimedCriticalAreaDamageShape: {
|
||||
version: 1,
|
||||
simulationReadiness: 'ready',
|
||||
scenarioInputs: [],
|
||||
pools: [],
|
||||
clauses: [
|
||||
{
|
||||
key: 'aimed-critical-explosion',
|
||||
label: 'Aimed critical hit explosion',
|
||||
event: 'weapon_hit',
|
||||
if: {
|
||||
kind: 'all',
|
||||
predicates: [
|
||||
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
||||
{
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.critical' },
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
{
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.aiming' },
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [
|
||||
{
|
||||
key: 'critical-explosion-damage',
|
||||
label: 'Critical explosion damage',
|
||||
phase: 'after_event',
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
basis: 'flat',
|
||||
targeting: {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 5,
|
||||
},
|
||||
variants: {
|
||||
'replace-with-each-required-variant-key': {
|
||||
value: 2000,
|
||||
expression: {
|
||||
op: 'multiply',
|
||||
left: 20,
|
||||
right: {
|
||||
ref: {
|
||||
source: 'attribute',
|
||||
attributeId: 'replace-with-an-exact-catalog-attribute-id',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
preciseTraumaShape: {
|
||||
version: 1,
|
||||
simulationReadiness: 'ready',
|
||||
@@ -453,9 +551,16 @@ export function buildAiMechanicAuthoringInput(input: {
|
||||
'A target-scoped pool owns an independent value and expiry timer for each entity. Bind hit-created target state with entity:{kind:"event_target"}; do not collapse different enemies into one Agent-scoped pool.',
|
||||
'Writing a target-scoped pool refreshes only that target entity timer. An on_exit outcome runs when that entity expires and uses the entity previous state for stack bands and perStack multiplication, even though the stored pool value has already reset.',
|
||||
'When an outcome value is exactly a live Attribute amount, use the Attribute reference directly as the variant expression. Divide by 100 only when a displayed percentage is being used as a multiplier.',
|
||||
'A weapon_hit may compare values.aiming with true only when the wording requires aiming down sights at hit resolution. Missing aiming evidence does not match; do not replace it with a scenario assumption.',
|
||||
'An explosion centered on the hit target uses target-scoped direct_damage with targeting kind event_target_area, center event_target, validTargets enemies, and the stated positive radiusMeters. The runtime applies one equal damage emission to each authoritative valid enemy at or inside the radius. Do not use legacy within_radius metadata for explosion damage.',
|
||||
'Precise Trauma is representable as one target-scoped stack pool with a one-second expiry: body shots add one stack, headshots add two, and one on_exit direct-damage outcome uses the expiring target previous stack count. This models the stated one-second debuff without inventing generic repeated target ticks.',
|
||||
'OS Protocol upgrade scaling may use equipment.protocolTalentUpgrades only when allowProtocolUpgradeScaling is true.',
|
||||
'Assigned-ability scope may be used only when allowAssignedAbility is true.',
|
||||
'When allowAssignedAbility is true, a kill or assist that must occur while the source item\'s assigned ability is active uses enemy_killed or assist with {source:"event",path:"values.assignedAbilityActive"} compared to true. This field is optional on the event; a missing value does not match.',
|
||||
'When allowAssignedAbility is true, requiresAssignedAbilityActive gates a clause on the runtime assigned-ability state. skill_deployed and signature_ability_deployed may supply values.assignedAbilityDurationSeconds to begin that state. At an exact expiry timestamp, the ability expires before a later clause can extend it.',
|
||||
'procChancePercent is a clause-level independent percentage roll after its predicate and cooldown checks but before the reducer, mutations, and outcomes. Use it when a successful proc changes state. probabilityPercent is an outcome-level independent percentage roll for one outcome only.',
|
||||
'Custom simulation uses a reproducible seeded random stream. Maximum Potential resolves every nonzero chance as successful. Never use clause weight as a proc chance.',
|
||||
'extend_duration may target only assigned_ability and does nothing while the ability is inactive. start_cooldown_on_max starts a configured expiring Agent cooldown pool only when a mutation in the same clause transitions its capped stack pool to its maximum.',
|
||||
'If cadence is not stated, ask rather than assuming a one-second interval.',
|
||||
'If wording contains separate actions such as restoration on a periodic snapshot and a health cost on every shot, represent them as separate clauses.',
|
||||
'A pool with expiresAfterSeconds refreshes the shared expiry for every current stack whenever any mutation writes that pool. Use it for mechanics where gaining a stack refreshes the duration of the full stack pool.',
|
||||
|
||||
@@ -9,21 +9,35 @@ describe('AI mechanic authoring rate limit', () => {
|
||||
beforeEach(() => resetAiMechanicRateLimitForTests());
|
||||
|
||||
it('allows only one in-flight request per account', () => {
|
||||
const release = reserveAiMechanicGeneration('agent-1', 1_000);
|
||||
expect(() => reserveAiMechanicGeneration('agent-1', 1_001)).toThrow(
|
||||
const release = reserveAiMechanicGeneration('agent-1', false, 1_000);
|
||||
expect(() => reserveAiMechanicGeneration('agent-1', false, 1_001)).toThrow(
|
||||
AiMechanicRateLimitError,
|
||||
);
|
||||
release();
|
||||
expect(() => reserveAiMechanicGeneration('agent-1', 1_002)).not.toThrow();
|
||||
expect(() =>
|
||||
reserveAiMechanicGeneration('agent-1', false, 1_002),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('enforces the hourly account limit', () => {
|
||||
for (let request = 0; request < 10; request += 1) {
|
||||
const release = reserveAiMechanicGeneration('agent-1', 10_000 + request);
|
||||
const release = reserveAiMechanicGeneration(
|
||||
'agent-1',
|
||||
false,
|
||||
10_000 + request,
|
||||
);
|
||||
release();
|
||||
}
|
||||
expect(() => reserveAiMechanicGeneration('agent-1', 10_100)).toThrow(
|
||||
expect(() => reserveAiMechanicGeneration('agent-1', false, 10_100)).toThrow(
|
||||
'This account has reached the hourly mechanic authoring limit.',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not limit administrators', () => {
|
||||
const releases = Array.from({ length: 101 }, () =>
|
||||
reserveAiMechanicGeneration('admin-1', true, 10_000),
|
||||
);
|
||||
|
||||
for (const release of releases) release();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,12 @@ const retryAfter = (requests: number[], now: number) =>
|
||||
* duplicate submissions and basic abuse; OpenAI project budgets remain the hard
|
||||
* cross-instance spending boundary.
|
||||
*/
|
||||
export function reserveAiMechanicGeneration(userId: string, now = Date.now()) {
|
||||
export function reserveAiMechanicGeneration(
|
||||
userId: string,
|
||||
isAdmin = false,
|
||||
now = Date.now(),
|
||||
) {
|
||||
if (isAdmin) return () => {};
|
||||
if (inFlightUsers.has(userId)) {
|
||||
throw new AiMechanicRateLimitError(
|
||||
'A mechanic authoring request is already running for this account.',
|
||||
|
||||
@@ -34,6 +34,7 @@ export async function recordIsacAudit(input: {
|
||||
export async function auditRecord(
|
||||
table: string,
|
||||
id: string,
|
||||
suppliedDatabase?: Database,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
if (
|
||||
![
|
||||
@@ -56,7 +57,7 @@ export async function auditRecord(
|
||||
) {
|
||||
throw new Error('Unsupported audit table');
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const database = suppliedDatabase ?? (await getDatabase());
|
||||
return (
|
||||
(await (
|
||||
await database.prepare(`SELECT * FROM ${table} WHERE id=?`)
|
||||
|
||||
@@ -187,6 +187,7 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
value_scaling TEXT, unit TEXT, damage_basis TEXT, application_phase TEXT,
|
||||
combat_timing TEXT, resource_kind TEXT, delivery_mode TEXT,
|
||||
delivery_duration_seconds REAL, targeting_json TEXT, min_state_value REAL, max_state_value REAL,
|
||||
probability_percent REAL,
|
||||
description_slot INTEGER, sort_order INTEGER, created_at TEXT, updated_at TEXT
|
||||
)`);
|
||||
|
||||
@@ -220,6 +221,8 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
key: 'weapon-kill',
|
||||
label: 'Weapon kill',
|
||||
event: 'enemy_killed',
|
||||
procChancePercent: 29,
|
||||
requiresAssignedAbilityActive: true,
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: {
|
||||
@@ -255,6 +258,7 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
population: 'allies',
|
||||
radiusMeters: 40,
|
||||
},
|
||||
probabilityPercent: 29,
|
||||
variants: {
|
||||
I: { value: 240 },
|
||||
II: { value: 336 },
|
||||
@@ -263,12 +267,18 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
{
|
||||
key: 'target-detonation',
|
||||
label: 'Target Detonation',
|
||||
phase: 'on_exit',
|
||||
phase: 'after_event',
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
basis: 'flat',
|
||||
statePool: 'marked-target',
|
||||
perStack: true,
|
||||
targeting: {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 5,
|
||||
},
|
||||
variants: {
|
||||
I: {
|
||||
value: 240,
|
||||
@@ -322,6 +332,23 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
mutations: [],
|
||||
outcomes: [],
|
||||
},
|
||||
{
|
||||
key: 'tactical-link-assist',
|
||||
label: 'Tactical Link assist',
|
||||
event: 'assist',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: {
|
||||
source: 'event',
|
||||
path: 'values.assignedAbilityActive',
|
||||
},
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
@@ -366,11 +393,23 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
population: 'allies',
|
||||
radiusMeters: 40,
|
||||
},
|
||||
probabilityPercent: 29,
|
||||
variants: {
|
||||
I: { value: 240 },
|
||||
II: { value: 336 },
|
||||
},
|
||||
});
|
||||
expect(loaded?.clauses[0].outcomes[1]).toMatchObject({
|
||||
phase: 'after_event',
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
targeting: {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 5,
|
||||
},
|
||||
});
|
||||
expect(loaded?.clauses[0].if).toEqual({
|
||||
kind: 'compare',
|
||||
left: {
|
||||
@@ -381,6 +420,10 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
operator: 'lt',
|
||||
right: 75,
|
||||
});
|
||||
expect(loaded?.clauses[0]).toMatchObject({
|
||||
procChancePercent: 29,
|
||||
requiresAssignedAbilityActive: true,
|
||||
});
|
||||
expect(loaded?.pools[0]).toMatchObject({
|
||||
key: 'marked-target',
|
||||
stateKind: 'target_mark',
|
||||
@@ -399,7 +442,7 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
},
|
||||
]);
|
||||
expect(loaded?.clauses[0].outcomes[1]).toMatchObject({
|
||||
phase: 'on_exit',
|
||||
phase: 'after_event',
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
statePool: 'marked-target',
|
||||
@@ -430,5 +473,14 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
right: true,
|
||||
},
|
||||
});
|
||||
expect(loaded?.clauses[2]).toMatchObject({
|
||||
event: 'assist',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.assignedAbilityActive' },
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +90,7 @@ const outcomeMetadataColumns = [
|
||||
'delivery_mode',
|
||||
'delivery_duration_seconds',
|
||||
'targeting_json',
|
||||
'probability_percent',
|
||||
'state_pool_id',
|
||||
'min_state_value',
|
||||
'max_state_value',
|
||||
@@ -205,6 +206,10 @@ export function conditionalRuleDraftFromRows(
|
||||
.filter((row) => row.rule_id === rule.id && Number(row.active ?? 1) === 1)
|
||||
.sort((left, right) => Number(left.sort_order) - Number(right.sort_order))
|
||||
.map((row): ConditionalMechanicClauseDraft => {
|
||||
const parameters = parseJson<Record<string, unknown>>(
|
||||
row.recipe_parameters_json,
|
||||
{},
|
||||
);
|
||||
const grouped = new Map<string, OutcomeRow[]>();
|
||||
for (const outcome of rowsByClause.get(String(row.id)) ?? []) {
|
||||
const key = String(outcome.effect_key);
|
||||
@@ -268,6 +273,10 @@ export function conditionalRuleDraftFromRows(
|
||||
...(first.targeting_json
|
||||
? { targeting: parseJson(first.targeting_json, undefined) }
|
||||
: {}),
|
||||
...(first.probability_percent === null ||
|
||||
first.probability_percent === undefined
|
||||
? {}
|
||||
: { probabilityPercent: Number(first.probability_percent) }),
|
||||
...(first.stat_attribute_id
|
||||
? { attributeId: String(first.stat_attribute_id) }
|
||||
: {}),
|
||||
@@ -329,6 +338,15 @@ export function conditionalRuleDraftFromRows(
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(typeof parameters.procChancePercent === 'number'
|
||||
? { procChancePercent: parameters.procChancePercent }
|
||||
: {}),
|
||||
...(typeof parameters.requiresAssignedAbilityActive === 'boolean'
|
||||
? {
|
||||
requiresAssignedAbilityActive:
|
||||
parameters.requiresAssignedAbilityActive,
|
||||
}
|
||||
: {}),
|
||||
...(row.cooldown_seconds === null || row.cooldown_seconds === undefined
|
||||
? {}
|
||||
: { cooldownSeconds: Number(row.cooldown_seconds) }),
|
||||
@@ -495,7 +513,17 @@ export async function saveConditionalRuleInTransaction(
|
||||
clause.label || clause.key,
|
||||
clause.event,
|
||||
clause.recipeKey ?? null,
|
||||
'{}',
|
||||
JSON.stringify({
|
||||
...(clause.procChancePercent === undefined
|
||||
? {}
|
||||
: { procChancePercent: clause.procChancePercent }),
|
||||
...(clause.requiresAssignedAbilityActive === undefined
|
||||
? {}
|
||||
: {
|
||||
requiresAssignedAbilityActive:
|
||||
clause.requiresAssignedAbilityActive,
|
||||
}),
|
||||
}),
|
||||
JSON.stringify(clause.if ?? { kind: 'true' }),
|
||||
JSON.stringify(clause.reducer),
|
||||
clause.weight === undefined ? null : JSON.stringify(clause.weight),
|
||||
@@ -518,9 +546,9 @@ export async function saveConditionalRuleInTransaction(
|
||||
application_kind,effect_scope,stat_attribute_id,value,value_expression_json,
|
||||
value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing,
|
||||
resource_kind,delivery_mode,delivery_duration_seconds,
|
||||
targeting_json,min_state_value,max_state_value,description_slot,sort_order,
|
||||
targeting_json,probability_percent,min_state_value,max_state_value,description_slot,sort_order,
|
||||
created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
).run(
|
||||
randomUUID(),
|
||||
ruleId,
|
||||
@@ -552,6 +580,7 @@ export async function saveConditionalRuleInTransaction(
|
||||
outcome.targeting === undefined
|
||||
? null
|
||||
: JSON.stringify(outcome.targeting),
|
||||
outcome.probabilityPercent ?? null,
|
||||
outcome.minStacks ?? null,
|
||||
outcome.maxStacks ?? null,
|
||||
outcome.descriptionSlot ?? null,
|
||||
|
||||
@@ -37,14 +37,31 @@ const proposalResultKeys: Partial<
|
||||
saveAugmentProfile: { saved: 'augmentProfileSaved' },
|
||||
},
|
||||
weapons: { save: { saved: 'weaponSaved', mode: 'weaponMode' } },
|
||||
weaponmods: { save: { saved: 'weaponModSaved', mode: 'weaponModMode' } },
|
||||
gearmods: { save: { saved: 'gearModSaved', mode: 'gearModMode' } },
|
||||
weaponmods: {
|
||||
save: { saved: 'weaponModSaved', mode: 'weaponModMode' },
|
||||
saveSlotIcon: { saved: 'weaponModSlotIconSaved' },
|
||||
saveSecondaryPool: {
|
||||
saved: 'weaponModSecondaryPoolSaved',
|
||||
mode: 'weaponModSecondaryPoolMode',
|
||||
},
|
||||
},
|
||||
gearmods: {
|
||||
save: { saved: 'gearModSaved', mode: 'gearModMode' },
|
||||
saveSecondaryPool: {
|
||||
saved: 'gearModSecondaryPoolSaved',
|
||||
mode: 'gearModSecondaryPoolMode',
|
||||
},
|
||||
},
|
||||
talents: { saveTalent: { saved: 'talentSaved', mode: 'talentMode' } },
|
||||
protocols: {
|
||||
save: { saved: 'protocolSaved', mode: 'protocolMode' },
|
||||
saveProfile: { saved: 'profileSaved' },
|
||||
},
|
||||
skillchips: { save: { saved: 'skillChipSaved', mode: 'skillChipMode' } },
|
||||
skillchips: {
|
||||
save: { saved: 'skillChipSaved', mode: 'skillChipMode' },
|
||||
savePrimaryProfile: { saved: 'skillChipPrimaryProfileSaved' },
|
||||
saveSecondaryPool: { saved: 'skillChipSecondaryPoolSaved' },
|
||||
},
|
||||
specializations: {
|
||||
saveProgression: { saved: 'progressionSaved' },
|
||||
saveSpecialization: {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
BackgroundRemovalError,
|
||||
removeBackgroundPreview,
|
||||
} from './remove-background';
|
||||
import { weaponModSlotDefinitionById } from '$lib/weapons/mod-slots';
|
||||
|
||||
export const GAME_ICON_TYPES = [
|
||||
'talent',
|
||||
@@ -16,6 +17,7 @@ export const GAME_ICON_TYPES = [
|
||||
'specialization',
|
||||
'specialization_ability',
|
||||
'specialization_node',
|
||||
'weapon_mod_slot',
|
||||
] as const;
|
||||
export type GameDataIconType = (typeof GAME_ICON_TYPES)[number];
|
||||
export const MAX_GAME_ICON_BYTES = 2 * 1024 * 1024;
|
||||
@@ -23,7 +25,7 @@ export const MAX_GAME_ICON_PIXELS = 12_000_000;
|
||||
export const GAME_ICON_SIZE = 256;
|
||||
const ENTITY_ID_PATTERN = /^[0-9a-f-]{36}$/i;
|
||||
const STORAGE_KEY_PATTERN =
|
||||
/^(talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
|
||||
/^(talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i;
|
||||
const LIBRARY_CATEGORY: Record<GameDataIconType, string> = {
|
||||
talent: 'Talents',
|
||||
brand_set: 'Brands',
|
||||
@@ -32,6 +34,7 @@ const LIBRARY_CATEGORY: Record<GameDataIconType, string> = {
|
||||
specialization: 'Specializations',
|
||||
specialization_ability: 'Specializations',
|
||||
specialization_node: 'Specializations',
|
||||
weapon_mod_slot: 'Weapon Mods',
|
||||
};
|
||||
const LIBRARY_LABEL: Record<GameDataIconType, string> = {
|
||||
talent: 'Talent',
|
||||
@@ -41,6 +44,7 @@ const LIBRARY_LABEL: Record<GameDataIconType, string> = {
|
||||
specialization: 'Specialization',
|
||||
specialization_ability: 'Specialization Ability',
|
||||
specialization_node: 'Specialization Tree Node',
|
||||
weapon_mod_slot: 'Weapon Mod Slot',
|
||||
};
|
||||
|
||||
export class GameDataIconValidationError extends Error {}
|
||||
@@ -255,6 +259,13 @@ async function libraryMetadata(
|
||||
],
|
||||
};
|
||||
}
|
||||
case 'weapon_mod_slot': {
|
||||
const definition = weaponModSlotDefinitionById(entityId);
|
||||
return {
|
||||
name: definition ? `${definition.name} Slot` : null,
|
||||
tags: ['weapon mod', 'slot', definition?.name],
|
||||
};
|
||||
}
|
||||
}
|
||||
return { name: null, tags: [] };
|
||||
}
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const { rmMock } = vi.hoisted(() => ({ rmMock: vi.fn() }));
|
||||
const { mkdirMock, rmMock, writeFileMock } = vi.hoisted(() => ({
|
||||
mkdirMock: vi.fn(),
|
||||
rmMock: vi.fn(),
|
||||
writeFileMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('node:fs/promises')>()),
|
||||
mkdir: mkdirMock,
|
||||
rm: rmMock,
|
||||
writeFile: writeFileMock,
|
||||
}));
|
||||
|
||||
import { GAME_ICON_TYPES } from './game-data-icons';
|
||||
import {
|
||||
GAME_DATA_RENDER_TYPES,
|
||||
ManagedImageDeletionError,
|
||||
applyGameDataRender,
|
||||
deleteManagedImage,
|
||||
processManagedImage,
|
||||
} from './managed-images';
|
||||
@@ -26,6 +33,16 @@ class FakeDatabase {
|
||||
prepare(sql: string) {
|
||||
return Promise.resolve({
|
||||
get: async (...args: unknown[]) => {
|
||||
if (
|
||||
sql.startsWith('SELECT * FROM managed_images') &&
|
||||
sql.includes('source_entity_type')
|
||||
) {
|
||||
return [...this.images.values()].find(
|
||||
(row) =>
|
||||
row.source_entity_type === args[0] &&
|
||||
row.source_entity_id === args[1],
|
||||
);
|
||||
}
|
||||
if (sql.startsWith('SELECT * FROM managed_images'))
|
||||
return this.images.get(String(args[0]));
|
||||
if (sql.startsWith('SELECT * FROM game_data_icons')) {
|
||||
@@ -42,7 +59,64 @@ class FakeDatabase {
|
||||
}
|
||||
if (sql.startsWith('DELETE FROM managed_images')) {
|
||||
const row = this.images.get(String(args[0]));
|
||||
if (row?.storage_key === args[1]) this.images.delete(String(args[0]));
|
||||
if (!sql.includes('storage_key') || row?.storage_key === args[1])
|
||||
this.images.delete(String(args[0]));
|
||||
return;
|
||||
}
|
||||
if (sql.startsWith('UPDATE managed_images SET')) {
|
||||
const row = this.images.get(String(args[7]));
|
||||
if (!row) throw new Error('Missing managed image for update');
|
||||
this.images.set(String(args[7]), {
|
||||
...row,
|
||||
category: args[0],
|
||||
tags_json: args[1],
|
||||
is_icon: 0,
|
||||
force_white: 0,
|
||||
original_filename: args[2],
|
||||
byte_size: args[3],
|
||||
width: args[4],
|
||||
height: args[5],
|
||||
updated_at: args[6],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (sql.startsWith('INSERT INTO managed_images')) {
|
||||
const [
|
||||
id,
|
||||
name,
|
||||
category,
|
||||
tagsJson,
|
||||
isIcon,
|
||||
forceWhite,
|
||||
storageKey,
|
||||
originalFilename,
|
||||
byteSize,
|
||||
width,
|
||||
height,
|
||||
sourceType,
|
||||
sourceEntityId,
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
] = args;
|
||||
this.images.set(String(id), {
|
||||
id,
|
||||
name,
|
||||
category,
|
||||
tags_json: tagsJson,
|
||||
is_icon: isIcon,
|
||||
force_white: forceWhite,
|
||||
storage_key: storageKey,
|
||||
original_filename: originalFilename,
|
||||
byte_size: byteSize,
|
||||
width,
|
||||
height,
|
||||
source_entity_type: sourceType,
|
||||
source_entity_id: sourceEntityId,
|
||||
created_by: createdBy,
|
||||
created_at: createdAt,
|
||||
updated_at: updatedAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new Error(`Unexpected run: ${sql}`);
|
||||
@@ -210,3 +284,94 @@ describe('processManagedImage', () => {
|
||||
expect(metadata.height).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyGameDataRender', () => {
|
||||
beforeEach(() => {
|
||||
mkdirMock.mockReset().mockResolvedValue(undefined);
|
||||
rmMock.mockReset().mockResolvedValue(undefined);
|
||||
writeFileMock.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it.each(GAME_DATA_RENDER_TYPES)(
|
||||
'persists and returns authoritative %s render metadata',
|
||||
async (type) => {
|
||||
const database = new FakeDatabase();
|
||||
const result = await applyGameDataRender(
|
||||
database as never,
|
||||
type,
|
||||
sourceId,
|
||||
'Test Entity',
|
||||
{
|
||||
file: new File(['source'], 'source.png', { type: 'image/png' }),
|
||||
remove: false,
|
||||
processed: {
|
||||
output: Buffer.from('processed-render'),
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
},
|
||||
},
|
||||
'operator-1',
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
original_filename: 'source.png',
|
||||
byte_size: Buffer.byteLength('processed-render'),
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
source_entity_type: `${type}_render`,
|
||||
source_entity_id: sourceId,
|
||||
created_by: 'operator-1',
|
||||
created_at: expect.any(String),
|
||||
updated_at: expect.any(String),
|
||||
});
|
||||
expect(writeFileMock).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
it('returns refreshed replacement metadata without rewriting creator provenance', async () => {
|
||||
const database = new FakeDatabase();
|
||||
database.images.set(imageId, {
|
||||
id: imageId,
|
||||
name: 'Ability render',
|
||||
category: 'Specializations',
|
||||
tags_json: '[]',
|
||||
is_icon: 0,
|
||||
force_white: 0,
|
||||
storage_key: `library-${imageId}.webp`,
|
||||
original_filename: 'old.png',
|
||||
byte_size: 3,
|
||||
width: 100,
|
||||
height: 100,
|
||||
source_entity_type: 'specialization_ability_render',
|
||||
source_entity_id: sourceId,
|
||||
created_by: 'original-operator',
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
updated_at: '2026-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const result = await applyGameDataRender(
|
||||
database as never,
|
||||
'specialization_ability',
|
||||
sourceId,
|
||||
'Ability',
|
||||
{
|
||||
file: new File(['new'], 'new.png', { type: 'image/png' }),
|
||||
remove: false,
|
||||
processed: {
|
||||
output: Buffer.from('replacement'),
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
},
|
||||
},
|
||||
'replacement-operator',
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: imageId,
|
||||
original_filename: 'new.png',
|
||||
byte_size: Buffer.byteLength('replacement'),
|
||||
created_by: 'original-operator',
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ export type GameDataRenderType = (typeof GAME_DATA_RENDER_TYPES)[number];
|
||||
const MAX_PIXELS = 24_000_000;
|
||||
const ID_PATTERN = /^[0-9a-f-]{36}$/i;
|
||||
const KEY_PATTERN =
|
||||
/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
|
||||
/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i;
|
||||
|
||||
export class ManagedImageValidationError extends Error {}
|
||||
export class ManagedImageDeletionError extends Error {}
|
||||
@@ -250,7 +250,7 @@ export async function applyGameDataRender(
|
||||
);
|
||||
await (
|
||||
await database.prepare(`UPDATE managed_images SET category=?,tags_json=?,is_icon=0,force_white=0,
|
||||
original_filename=?,byte_size=?,width=?,height=?,created_by=?,updated_at=? WHERE id=?`)
|
||||
original_filename=?,byte_size=?,width=?,height=?,updated_at=? WHERE id=?`)
|
||||
).run(
|
||||
category[type],
|
||||
tags,
|
||||
@@ -258,19 +258,14 @@ export async function applyGameDataRender(
|
||||
processed.output.length,
|
||||
processed.width,
|
||||
processed.height,
|
||||
actorId,
|
||||
now,
|
||||
existing.id,
|
||||
);
|
||||
return {
|
||||
...existing,
|
||||
category: category[type],
|
||||
tags_json: tags,
|
||||
byte_size: processed.output.length,
|
||||
width: processed.width,
|
||||
height: processed.height,
|
||||
updated_at: now,
|
||||
};
|
||||
return (
|
||||
(await (
|
||||
await database.prepare('SELECT * FROM managed_images WHERE id=?')
|
||||
).get(existing.id)) ?? null
|
||||
);
|
||||
}
|
||||
const id = randomUUID();
|
||||
const storageKey = await saveManagedImageFile(id, processed.output);
|
||||
@@ -298,20 +293,11 @@ export async function applyGameDataRender(
|
||||
now,
|
||||
now,
|
||||
);
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
category: category[type],
|
||||
tags_json: tags,
|
||||
is_icon: 0,
|
||||
storage_key: storageKey,
|
||||
byte_size: processed.output.length,
|
||||
width: processed.width,
|
||||
height: processed.height,
|
||||
source_entity_type: sourceType,
|
||||
source_entity_id: entityId,
|
||||
updated_at: now,
|
||||
};
|
||||
return (
|
||||
(await (
|
||||
await database.prepare('SELECT * FROM managed_images WHERE id=?')
|
||||
).get(id)) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteManagedImage(database: Database, id: string) {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import {
|
||||
MOD_SECONDARY_QUALITIES,
|
||||
normalizeModSecondaryPool,
|
||||
type ModSecondaryRange,
|
||||
} from '$lib/mods/secondary-pool';
|
||||
|
||||
export type ModSecondaryPoolKind = 'weapon' | 'gear';
|
||||
|
||||
function tables(kind: ModSecondaryPoolKind) {
|
||||
return {
|
||||
options: `${kind}_mod_secondary_options`,
|
||||
values: `${kind}_mod_secondary_quality_values`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getModSecondaryPool(
|
||||
database: Database,
|
||||
kind: ModSecondaryPoolKind,
|
||||
): Promise<ModSecondaryRange[]> {
|
||||
const table = tables(kind);
|
||||
const [options, values] = await Promise.all([
|
||||
(
|
||||
await database.prepare(`SELECT stat_attribute_id FROM ${table.options}
|
||||
ORDER BY stat_attribute_id`)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(`SELECT * FROM ${table.values}
|
||||
ORDER BY stat_attribute_id,quality`)
|
||||
).all(),
|
||||
]);
|
||||
|
||||
const pool = options.map((option) => {
|
||||
const optionValues = values.filter(
|
||||
(value) => value.stat_attribute_id === option.stat_attribute_id,
|
||||
);
|
||||
if (
|
||||
optionValues.length !== MOD_SECONDARY_QUALITIES.length ||
|
||||
MOD_SECONDARY_QUALITIES.some(
|
||||
(quality) => !optionValues.some((value) => value.quality === quality),
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`${kind} Mod secondary Attribute ${String(option.stat_attribute_id)} does not have a complete quality profile.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
statId: String(option.stat_attribute_id),
|
||||
values: Object.fromEntries(
|
||||
optionValues.map((value) => [
|
||||
String(value.quality),
|
||||
{
|
||||
level1Min: Number(value.level_1_min),
|
||||
level1Max: Number(value.level_1_max),
|
||||
level20Min: Number(value.level_20_min),
|
||||
level20Max: Number(value.level_20_max),
|
||||
},
|
||||
]),
|
||||
) as ModSecondaryRange['values'],
|
||||
};
|
||||
});
|
||||
if (!pool.length) return [];
|
||||
const normalized = normalizeModSecondaryPool(pool);
|
||||
if (normalized.error) {
|
||||
throw new Error(`${kind} Mod secondary pool: ${normalized.error}`);
|
||||
}
|
||||
return normalized.pool;
|
||||
}
|
||||
|
||||
export async function replaceModSecondaryPool(
|
||||
database: Database,
|
||||
kind: ModSecondaryPoolKind,
|
||||
pool: ModSecondaryRange[],
|
||||
) {
|
||||
const table = tables(kind);
|
||||
await database.exec(`DELETE FROM ${table.values}`);
|
||||
await database.exec(`DELETE FROM ${table.options}`);
|
||||
for (const range of pool) {
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO ${table.options}(stat_attribute_id) VALUES(?)`,
|
||||
)
|
||||
).run(range.statId);
|
||||
for (const quality of MOD_SECONDARY_QUALITIES) {
|
||||
const values = range.values[quality];
|
||||
await (
|
||||
await database.prepare(`INSERT INTO ${table.values}
|
||||
(stat_attribute_id,quality,level_1_min,level_1_max,level_20_min,level_20_max)
|
||||
VALUES(?,?,?,?,?,?)`)
|
||||
).run(
|
||||
range.statId,
|
||||
quality,
|
||||
values.level1Min,
|
||||
values.level1Max,
|
||||
values.level20Min,
|
||||
values.level20Max,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { connect } from '@tursodatabase/database';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getOsProtocolProfileAttributeIds,
|
||||
type OsProtocolAttributeType,
|
||||
} from './os-protocol-profiles';
|
||||
|
||||
const databases: Array<Awaited<ReturnType<typeof connect>>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(databases.splice(0).map((database) => database.close()));
|
||||
});
|
||||
|
||||
async function databaseWithAttributes() {
|
||||
const database = await connect(':memory:');
|
||||
databases.push(database);
|
||||
await database.exec(`CREATE TABLE stat_attributes (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
unit TEXT NOT NULL,
|
||||
active INTEGER NOT NULL
|
||||
)`);
|
||||
for (const [id, name, unit] of [
|
||||
['firepower-flat', 'Firepower', ''],
|
||||
['firepower-percent', 'Firepower', '%'],
|
||||
['toughness-flat', 'Toughness', ''],
|
||||
['toughness-percent', 'Toughness', '%'],
|
||||
['engineering-flat', 'Engineering', ''],
|
||||
['weapon-damage-flat', 'Weapon Damage', ''],
|
||||
['weapon-damage-percent', 'Weapon Damage', '%'],
|
||||
['skill-damage-percent', 'Skill Damage', '%'],
|
||||
['health-flat', 'Health', ''],
|
||||
]) {
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO stat_attributes(id,name,unit,active) VALUES(?,?,?,1)',
|
||||
)
|
||||
).run(id, name, unit);
|
||||
}
|
||||
return database;
|
||||
}
|
||||
|
||||
describe('OS Protocol profile Attributes', () => {
|
||||
it.each([
|
||||
[
|
||||
'Firepower',
|
||||
{
|
||||
Core: 'firepower-flat',
|
||||
Damage: 'weapon-damage-flat',
|
||||
Health: 'health-flat',
|
||||
},
|
||||
],
|
||||
[
|
||||
'Toughness',
|
||||
{
|
||||
Core: 'toughness-flat',
|
||||
Damage: 'weapon-damage-flat',
|
||||
Health: 'health-flat',
|
||||
},
|
||||
],
|
||||
[
|
||||
'Engineering',
|
||||
{
|
||||
Core: 'engineering-flat',
|
||||
Damage: 'skill-damage-percent',
|
||||
Health: 'health-flat',
|
||||
},
|
||||
],
|
||||
] as const)('resolves the exact %s role rows', async (type, expected) => {
|
||||
const database = await databaseWithAttributes();
|
||||
|
||||
await expect(
|
||||
getOsProtocolProfileAttributeIds(
|
||||
database,
|
||||
type as OsProtocolAttributeType,
|
||||
),
|
||||
).resolves.toEqual(expected);
|
||||
});
|
||||
|
||||
it('reports a missing required role without selecting a similarly named row', async () => {
|
||||
const database = await databaseWithAttributes();
|
||||
await (
|
||||
await database.prepare(
|
||||
"DELETE FROM stat_attributes WHERE id='weapon-damage-flat'",
|
||||
)
|
||||
).run();
|
||||
|
||||
await expect(
|
||||
getOsProtocolProfileAttributeIds(database, 'Firepower'),
|
||||
).resolves.toEqual({
|
||||
Core: 'firepower-flat',
|
||||
Damage: null,
|
||||
Health: 'health-flat',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
|
||||
export const OS_PROTOCOL_PROFILE_ATTRIBUTE_SPECS = {
|
||||
Firepower: {
|
||||
Core: { name: 'Firepower', unit: '' },
|
||||
Damage: { name: 'Weapon Damage', unit: '' },
|
||||
Health: { name: 'Health', unit: '' },
|
||||
},
|
||||
Toughness: {
|
||||
Core: { name: 'Toughness', unit: '' },
|
||||
Damage: { name: 'Weapon Damage', unit: '' },
|
||||
Health: { name: 'Health', unit: '' },
|
||||
},
|
||||
Engineering: {
|
||||
Core: { name: 'Engineering', unit: '' },
|
||||
Damage: { name: 'Skill Damage', unit: '%' },
|
||||
Health: { name: 'Health', unit: '' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type OsProtocolAttributeType =
|
||||
keyof typeof OS_PROTOCOL_PROFILE_ATTRIBUTE_SPECS;
|
||||
export type OsProtocolSecondaryRole = 'Core' | 'Damage' | 'Health';
|
||||
|
||||
export async function getOsProtocolProfileAttributeIds(
|
||||
database: Database,
|
||||
attributeType: OsProtocolAttributeType,
|
||||
) {
|
||||
const specs = OS_PROTOCOL_PROFILE_ATTRIBUTE_SPECS[attributeType];
|
||||
const entries = await Promise.all(
|
||||
(
|
||||
Object.entries(specs) as Array<
|
||||
[OsProtocolSecondaryRole, { name: string; unit: string }]
|
||||
>
|
||||
).map(async ([role, spec]) => {
|
||||
const row = await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM stat_attributes WHERE active=1 AND name=? COLLATE NOCASE AND unit=?',
|
||||
)
|
||||
).get(spec.name, spec.unit);
|
||||
return [role, row ? String(row.id) : null] as const;
|
||||
}),
|
||||
);
|
||||
return Object.fromEntries(entries) as Record<
|
||||
OsProtocolSecondaryRole,
|
||||
string | null
|
||||
>;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
|
||||
import { hydrateSkillChipProfileRows } from '$lib/skill-chips/profiles';
|
||||
|
||||
export async function loadSharedBuildSummaryCatalog(database: Database) {
|
||||
const [
|
||||
@@ -16,6 +17,10 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
||||
talentTemplates,
|
||||
chipSets,
|
||||
chipTwoPiece,
|
||||
chipPrimaryOptions,
|
||||
chipPrimaryQualityValues,
|
||||
chipSecondaryOptions,
|
||||
chipSecondaryQualityValues,
|
||||
chipTalents,
|
||||
chipTalentEffects,
|
||||
protocols,
|
||||
@@ -83,6 +88,18 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
||||
(
|
||||
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM skill_chip_primary_quality_values')
|
||||
).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM skill_chip_secondary_options')
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM skill_chip_secondary_quality_values',
|
||||
)
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
@@ -161,6 +178,16 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
||||
),
|
||||
})),
|
||||
})),
|
||||
skillChipProfile: {
|
||||
primaryOptions: hydrateSkillChipProfileRows(
|
||||
chipPrimaryOptions,
|
||||
chipPrimaryQualityValues,
|
||||
),
|
||||
secondaryOptions: hydrateSkillChipProfileRows(
|
||||
chipSecondaryOptions,
|
||||
chipSecondaryQualityValues,
|
||||
),
|
||||
},
|
||||
chipSets: chipSets.map((set) => {
|
||||
const talent = chipTalents.find(
|
||||
(item) => item.skill_chip_set_id === set.id,
|
||||
|
||||
@@ -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,8 +17,14 @@ export async function refreshSpecializationFocusReady(
|
||||
abilityCount: 0,
|
||||
chipReadyCount: 0,
|
||||
};
|
||||
const [config, abilityCountRow, chipReadyRow, nodes, rankProfiles] =
|
||||
await Promise.all([
|
||||
const [
|
||||
config,
|
||||
abilityCountRow,
|
||||
profileRow,
|
||||
chipReadyRow,
|
||||
nodes,
|
||||
rankProfiles,
|
||||
] = await Promise.all([
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT xp_points,shd_cache_points FROM specialization_progression_config WHERE id='default'",
|
||||
@@ -29,6 +35,14 @@ export async function refreshSpecializationFocusReady(
|
||||
JOIN specialization_abilities ability ON ability.id=link.ability_id
|
||||
WHERE link.focus_id=? AND ability.active=1`)
|
||||
).get(focusId),
|
||||
(
|
||||
await database.prepare(`SELECT
|
||||
(SELECT COUNT(*) FROM skill_chip_primary_options)=4
|
||||
AND (SELECT COUNT(*) FROM skill_chip_primary_quality_values)=16
|
||||
AND (SELECT COUNT(*) FROM skill_chip_secondary_options)>=2
|
||||
AND (SELECT COUNT(*) FROM skill_chip_secondary_quality_values)=
|
||||
(SELECT COUNT(*)*3 FROM skill_chip_secondary_options) AS ready`)
|
||||
).get(),
|
||||
(
|
||||
await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
|
||||
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
|
||||
@@ -69,6 +83,7 @@ export async function refreshSpecializationFocusReady(
|
||||
Number(focus.active) === 1 &&
|
||||
abilityCount === 3 &&
|
||||
chipReadyCount === 3 &&
|
||||
Number(profileRow?.ready) === 1 &&
|
||||
nodes.length > 0 &&
|
||||
contiguousRows &&
|
||||
pointTotal === availablePoints;
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
normalizeSkillChipProfiles,
|
||||
SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES,
|
||||
SKILL_CHIP_QUALITIES,
|
||||
SKILL_CHIP_SECONDARY_CAPACITY,
|
||||
SKILL_CHIP_SECONDARY_QUALITIES,
|
||||
} from './profiles';
|
||||
|
||||
const primaryIds = [
|
||||
'skill-duration',
|
||||
'skill-radius',
|
||||
'skill-cooldown-recovery',
|
||||
'skill-intensity',
|
||||
] as const;
|
||||
|
||||
const ranges = (qualities: readonly string[], offset = 0) =>
|
||||
Object.fromEntries(
|
||||
qualities.map((quality, index) => [
|
||||
quality,
|
||||
{ min: String(offset + index + 1), max: offset + index + 2 },
|
||||
]),
|
||||
);
|
||||
|
||||
const profile = (statId: string, qualities: readonly string[], offset = 0) => ({
|
||||
statId,
|
||||
values: ranges(qualities, offset),
|
||||
});
|
||||
|
||||
describe('Skill Chip shared profiles', () => {
|
||||
it('defines the exact four fixed primary Attributes and all four qualities', () => {
|
||||
expect(SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES).toEqual([
|
||||
'Skill Duration',
|
||||
'Skill Radius',
|
||||
'Skill Cooldown Recovery',
|
||||
'Skill Intensity',
|
||||
]);
|
||||
expect(SKILL_CHIP_QUALITIES).toEqual([
|
||||
'Standard',
|
||||
'Specialized',
|
||||
'Superior',
|
||||
'High-end',
|
||||
]);
|
||||
});
|
||||
|
||||
it('defines the 0/1/2/2 secondary capacity mapping', () => {
|
||||
expect(SKILL_CHIP_SECONDARY_CAPACITY).toEqual({
|
||||
Standard: 0,
|
||||
Specialized: 1,
|
||||
Superior: 2,
|
||||
'High-end': 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes exactly the four fixed primary profiles across every quality', () => {
|
||||
const raw = primaryIds.map((statId, index) =>
|
||||
profile(` ${statId} `, SKILL_CHIP_QUALITIES, index * 10),
|
||||
);
|
||||
|
||||
const result = normalizeSkillChipProfiles(raw, SKILL_CHIP_QUALITIES, {
|
||||
expectedStatIds: primaryIds,
|
||||
minimumOptions: 4,
|
||||
});
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.profiles).toHaveLength(4);
|
||||
expect(result.profiles.map(({ statId }) => statId)).toEqual(primaryIds);
|
||||
expect(result.profiles[0]).toEqual({
|
||||
statId: 'skill-duration',
|
||||
values: {
|
||||
Standard: { min: 1, max: 2 },
|
||||
Specialized: { min: 2, max: 3 },
|
||||
Superior: { min: 3, max: 4 },
|
||||
'High-end': { min: 4, max: 5 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a primary profile that does not contain the exact fixed set', () => {
|
||||
const raw = primaryIds.map((statId) =>
|
||||
profile(statId, SKILL_CHIP_QUALITIES),
|
||||
);
|
||||
raw[3] = profile('weapon-damage', SKILL_CHIP_QUALITIES);
|
||||
|
||||
expect(
|
||||
normalizeSkillChipProfiles(raw, SKILL_CHIP_QUALITIES, {
|
||||
expectedStatIds: primaryIds,
|
||||
minimumOptions: 4,
|
||||
}),
|
||||
).toEqual({
|
||||
profiles: [],
|
||||
error:
|
||||
'The primary profile must contain the four fixed Skill Attributes.',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes secondary profiles across only the three supported qualities', () => {
|
||||
expect(SKILL_CHIP_SECONDARY_QUALITIES).toEqual([
|
||||
'Specialized',
|
||||
'Superior',
|
||||
'High-end',
|
||||
]);
|
||||
const result = normalizeSkillChipProfiles(
|
||||
[
|
||||
profile(' critical-hit-chance ', SKILL_CHIP_SECONDARY_QUALITIES),
|
||||
profile('critical-hit-damage', SKILL_CHIP_SECONDARY_QUALITIES, 10),
|
||||
],
|
||||
SKILL_CHIP_SECONDARY_QUALITIES,
|
||||
{ minimumOptions: 2 },
|
||||
);
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.profiles[0]).toEqual({
|
||||
statId: 'critical-hit-chance',
|
||||
values: {
|
||||
Specialized: { min: 1, max: 2 },
|
||||
Superior: { min: 2, max: 3 },
|
||||
'High-end': { min: 3, max: 4 },
|
||||
},
|
||||
});
|
||||
expect(result.profiles[0].values).not.toHaveProperty('Standard');
|
||||
});
|
||||
|
||||
it('rejects duplicate Attributes after trimming IDs', () => {
|
||||
const result = normalizeSkillChipProfiles(
|
||||
[
|
||||
profile('critical-hit-chance', SKILL_CHIP_SECONDARY_QUALITIES),
|
||||
profile(' critical-hit-chance ', SKILL_CHIP_SECONDARY_QUALITIES),
|
||||
],
|
||||
SKILL_CHIP_SECONDARY_QUALITIES,
|
||||
{ minimumOptions: 2 },
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
profiles: [],
|
||||
error: 'Choose a unique Attribute for every shared option.',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'missing ranges',
|
||||
{
|
||||
Specialized: { min: 1, max: 2 },
|
||||
Superior: { min: 2, max: 3 },
|
||||
},
|
||||
'High-end requires a minimum and maximum.',
|
||||
],
|
||||
[
|
||||
'reversed ranges',
|
||||
{
|
||||
Specialized: { min: 2, max: 1 },
|
||||
Superior: { min: 2, max: 3 },
|
||||
'High-end': { min: 3, max: 4 },
|
||||
},
|
||||
'Specialized requires a valid non-negative range.',
|
||||
],
|
||||
[
|
||||
'negative ranges',
|
||||
{
|
||||
Specialized: { min: -1, max: 2 },
|
||||
Superior: { min: 2, max: 3 },
|
||||
'High-end': { min: 3, max: 4 },
|
||||
},
|
||||
'Specialized requires a valid non-negative range.',
|
||||
],
|
||||
])('rejects %s', (_label, values, error) => {
|
||||
const result = normalizeSkillChipProfiles(
|
||||
[
|
||||
{ statId: 'critical-hit-chance', values },
|
||||
profile('critical-hit-damage', SKILL_CHIP_SECONDARY_QUALITIES),
|
||||
],
|
||||
SKILL_CHIP_SECONDARY_QUALITIES,
|
||||
{ minimumOptions: 2 },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ profiles: [], error });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
export const SKILL_CHIP_QUALITIES = [
|
||||
'Standard',
|
||||
'Specialized',
|
||||
'Superior',
|
||||
'High-end',
|
||||
] as const;
|
||||
|
||||
export const SKILL_CHIP_SECONDARY_QUALITIES = [
|
||||
'Specialized',
|
||||
'Superior',
|
||||
'High-end',
|
||||
] as const;
|
||||
|
||||
export const SKILL_CHIP_SECONDARY_CAPACITY = {
|
||||
Standard: 0,
|
||||
Specialized: 1,
|
||||
Superior: 2,
|
||||
'High-end': 2,
|
||||
} as const satisfies Record<(typeof SKILL_CHIP_QUALITIES)[number], number>;
|
||||
|
||||
export const SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES = [
|
||||
'Skill Duration',
|
||||
'Skill Radius',
|
||||
'Skill Cooldown Recovery',
|
||||
'Skill Intensity',
|
||||
] as const;
|
||||
|
||||
export type SkillChipRange = { min: number; max: number };
|
||||
export type SkillChipProfile = {
|
||||
statId: string;
|
||||
values: Record<string, SkillChipRange>;
|
||||
};
|
||||
|
||||
export function hydrateSkillChipProfileRows(
|
||||
options: Array<Record<string, unknown>>,
|
||||
qualityValues: Array<Record<string, unknown>>,
|
||||
) {
|
||||
return options.map((option) => ({
|
||||
statId: String(option.stat_attribute_id),
|
||||
ranges: Object.fromEntries(
|
||||
qualityValues
|
||||
.filter((range) => range.stat_attribute_id === option.stat_attribute_id)
|
||||
.map((range) => [
|
||||
String(range.quality),
|
||||
{ min: Number(range.min_value), max: Number(range.max_value) },
|
||||
]),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
const finite = (value: unknown) => {
|
||||
if (value === '' || value === null || value === undefined) return null;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
};
|
||||
|
||||
export function normalizeSkillChipProfiles(
|
||||
raw: unknown,
|
||||
qualities: readonly string[],
|
||||
options: {
|
||||
expectedStatIds?: readonly string[];
|
||||
minimumOptions?: number;
|
||||
} = {},
|
||||
): { profiles: SkillChipProfile[]; error: string | null } {
|
||||
if (!Array.isArray(raw))
|
||||
return { profiles: [], error: 'The shared profile payload is invalid.' };
|
||||
if (raw.length < (options.minimumOptions ?? 1) || raw.length > 50)
|
||||
return {
|
||||
profiles: [],
|
||||
error: `Configure ${options.minimumOptions ?? 1}–50 shared Attribute options.`,
|
||||
};
|
||||
|
||||
const profiles: SkillChipProfile[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const entry of raw) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
||||
return { profiles: [], error: 'A shared Attribute option is invalid.' };
|
||||
const candidate = entry as Record<string, unknown>;
|
||||
const statId = String(candidate.statId ?? '').trim();
|
||||
if (!statId || seen.has(statId))
|
||||
return {
|
||||
profiles: [],
|
||||
error: 'Choose a unique Attribute for every shared option.',
|
||||
};
|
||||
seen.add(statId);
|
||||
const rawValues = candidate.values;
|
||||
if (!rawValues || typeof rawValues !== 'object' || Array.isArray(rawValues))
|
||||
return {
|
||||
profiles: [],
|
||||
error: 'Every shared Attribute needs quality ranges.',
|
||||
};
|
||||
const values: Record<string, SkillChipRange> = {};
|
||||
for (const quality of qualities) {
|
||||
const rawRange = (rawValues as Record<string, unknown>)[quality];
|
||||
if (!rawRange || typeof rawRange !== 'object' || Array.isArray(rawRange))
|
||||
return {
|
||||
profiles: [],
|
||||
error: `${quality} requires a minimum and maximum.`,
|
||||
};
|
||||
const range = rawRange as Record<string, unknown>;
|
||||
const min = finite(range.min);
|
||||
const max = finite(range.max);
|
||||
if (min === null || max === null || min < 0 || max < min)
|
||||
return {
|
||||
profiles: [],
|
||||
error: `${quality} requires a valid non-negative range.`,
|
||||
};
|
||||
values[quality] = { min, max };
|
||||
}
|
||||
profiles.push({ statId, values });
|
||||
}
|
||||
|
||||
if (options.expectedStatIds) {
|
||||
const expected = [...options.expectedStatIds].sort();
|
||||
const received = profiles.map((profile) => profile.statId).sort();
|
||||
if (
|
||||
expected.length !== received.length ||
|
||||
expected.some((statId, index) => statId !== received[index])
|
||||
)
|
||||
return {
|
||||
profiles: [],
|
||||
error:
|
||||
'The primary profile must contain the four fixed Skill Attributes.',
|
||||
};
|
||||
}
|
||||
return { profiles, error: null };
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
WEAPON_MOD_SLOT_DEFINITIONS,
|
||||
WEAPON_MOD_SLOT_TYPES,
|
||||
excludedTierZeroWeaponModSlot,
|
||||
expandSharedWeaponModSlots,
|
||||
normalizeWeaponModSlotPayload,
|
||||
publicWeaponModSlots,
|
||||
sharedWeaponModSlots,
|
||||
validateWeaponModSlots,
|
||||
weaponModSlotDefinitionById,
|
||||
weaponModSlotDefinitionByName,
|
||||
weaponModSlotsComplete,
|
||||
type WeaponModSlotConfig,
|
||||
} from './mod-slots';
|
||||
@@ -15,6 +20,23 @@ const configuration = (tier: number, slots: string[]): WeaponModSlotConfig => ({
|
||||
});
|
||||
|
||||
describe('weapon mod slot configuration', () => {
|
||||
it('assigns one stable UUID to every canonical slot type', () => {
|
||||
expect(WEAPON_MOD_SLOT_DEFINITIONS).toHaveLength(8);
|
||||
expect(new Set(WEAPON_MOD_SLOT_DEFINITIONS.map(({ id }) => id)).size).toBe(
|
||||
8,
|
||||
);
|
||||
expect(WEAPON_MOD_SLOT_DEFINITIONS.map(({ name }) => name)).toEqual(
|
||||
WEAPON_MOD_SLOT_TYPES,
|
||||
);
|
||||
for (const definition of WEAPON_MOD_SLOT_DEFINITIONS) {
|
||||
expect(definition.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
expect(weaponModSlotDefinitionById(definition.id)).toBe(definition);
|
||||
expect(weaponModSlotDefinitionByName(definition.name)).toBe(definition);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts missing and partial slot data for later configuration', () => {
|
||||
const slots = [
|
||||
configuration(0, []),
|
||||
@@ -99,4 +121,31 @@ describe('weapon mod slot configuration', () => {
|
||||
]),
|
||||
).toEqual([configuration(1, ['Optics', 'Muzzle', 'Barrel'])]);
|
||||
});
|
||||
|
||||
it('marks the slot gained by a native T0 weapon at T1', () => {
|
||||
const slots = expandSharedWeaponModSlots(
|
||||
['Magazine', 'Optics', 'Barrel', 'Muzzle', 'Underbarrel'],
|
||||
'Barrel',
|
||||
);
|
||||
|
||||
expect(publicWeaponModSlots(0, slots)).toEqual([
|
||||
{ slot: 'Optics', unlockedAtTierOne: false },
|
||||
{ slot: 'Muzzle', unlockedAtTierOne: false },
|
||||
{ slot: 'Underbarrel', unlockedAtTierOne: false },
|
||||
{ slot: 'Magazine', unlockedAtTierOne: false },
|
||||
{ slot: 'Barrel', unlockedAtTierOne: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows native T1 slots without an upgrade-only state', () => {
|
||||
expect(
|
||||
publicWeaponModSlots(1, [
|
||||
configuration(1, ['Magazine', 'Optics', 'Muzzle']),
|
||||
]),
|
||||
).toEqual([
|
||||
{ slot: 'Optics', unlockedAtTierOne: false },
|
||||
{ slot: 'Muzzle', unlockedAtTierOne: false },
|
||||
{ slot: 'Magazine', unlockedAtTierOne: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,42 @@
|
||||
export const WEAPON_MOD_SLOT_TYPES = [
|
||||
'Optics',
|
||||
'Muzzle',
|
||||
'Underbarrel',
|
||||
'Magazine',
|
||||
'Barrel',
|
||||
'Upper Receiver',
|
||||
'Lower Receiver',
|
||||
'Trigger',
|
||||
export const WEAPON_MOD_SLOT_DEFINITIONS = [
|
||||
{ id: '28ffc3b1-5d98-4273-a9af-67dbff3e5c76', name: 'Optics' },
|
||||
{ id: 'c4598f51-2cb0-4a20-a3a7-6d8983a26598', name: 'Muzzle' },
|
||||
{ id: '85ca3493-1871-4d22-950a-ed718fd547e0', name: 'Underbarrel' },
|
||||
{ id: '4d809a35-8285-4bff-be98-a0effe1d257e', name: 'Magazine' },
|
||||
{ id: '0e157cfe-975c-441d-8f49-72983161e7b1', name: 'Barrel' },
|
||||
{ id: '551fd7c8-3e5b-4c89-8437-9b901e89e8aa', name: 'Upper Receiver' },
|
||||
{ id: '72573367-6e91-4312-9cc3-710ede4f078e', name: 'Lower Receiver' },
|
||||
{ id: '8dda5edb-6079-4a23-a556-cd9be4521c70', name: 'Trigger' },
|
||||
] as const;
|
||||
|
||||
export type WeaponModSlotType =
|
||||
(typeof WEAPON_MOD_SLOT_DEFINITIONS)[number]['name'];
|
||||
|
||||
export const WEAPON_MOD_SLOT_TYPES = WEAPON_MOD_SLOT_DEFINITIONS.map(
|
||||
(definition) => definition.name,
|
||||
) as WeaponModSlotType[];
|
||||
|
||||
export function weaponModSlotDefinitionById(id: string) {
|
||||
return WEAPON_MOD_SLOT_DEFINITIONS.find((definition) => definition.id === id);
|
||||
}
|
||||
|
||||
export function weaponModSlotDefinitionByName(name: string) {
|
||||
return WEAPON_MOD_SLOT_DEFINITIONS.find(
|
||||
(definition) => definition.name === name,
|
||||
);
|
||||
}
|
||||
|
||||
export type WeaponModSlotConfig = {
|
||||
tier: number;
|
||||
slots: string[];
|
||||
};
|
||||
|
||||
export type PublicWeaponModSlot = {
|
||||
slot: string;
|
||||
unlockedAtTierOne: boolean;
|
||||
iconUrl?: string;
|
||||
};
|
||||
|
||||
export function normalizeWeaponModSlotPayload(
|
||||
payload: unknown,
|
||||
): WeaponModSlotConfig[] {
|
||||
@@ -52,6 +75,33 @@ export function sharedWeaponModSlots(
|
||||
];
|
||||
}
|
||||
|
||||
export function publicWeaponModSlots(
|
||||
nativeTier: number,
|
||||
configurations: WeaponModSlotConfig[],
|
||||
): PublicWeaponModSlot[] {
|
||||
const nativeSlots =
|
||||
configurations.find((row) => Number(row.tier) === nativeTier)?.slots ?? [];
|
||||
const displayedSlots =
|
||||
nativeTier === 0
|
||||
? sharedWeaponModSlots(configurations).length
|
||||
? sharedWeaponModSlots(configurations)
|
||||
: nativeSlots
|
||||
: nativeSlots;
|
||||
const ordered = [...new Set(displayedSlots)].sort(
|
||||
(left, right) =>
|
||||
WEAPON_MOD_SLOT_TYPES.indexOf(
|
||||
left as (typeof WEAPON_MOD_SLOT_TYPES)[number],
|
||||
) -
|
||||
WEAPON_MOD_SLOT_TYPES.indexOf(
|
||||
right as (typeof WEAPON_MOD_SLOT_TYPES)[number],
|
||||
),
|
||||
);
|
||||
return ordered.map((slot) => ({
|
||||
slot,
|
||||
unlockedAtTierOne: nativeTier === 0 && !nativeSlots.includes(slot),
|
||||
}));
|
||||
}
|
||||
|
||||
export function excludedTierZeroWeaponModSlot(
|
||||
sharedSlots: string[],
|
||||
configurations: WeaponModSlotConfig[],
|
||||
|
||||
@@ -92,6 +92,10 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => {
|
||||
WHERE users.id=?`)
|
||||
).get(agentId);
|
||||
const isSelf = viewer?.id === agentId;
|
||||
const canManageNews =
|
||||
isSelf &&
|
||||
(viewerPermissions.includes('*') ||
|
||||
viewerPermissions.includes('news.manage'));
|
||||
if (unavailable(agent) || (!Boolean(agent?.is_public) && !isSelf))
|
||||
error(404, 'Agent profile not found');
|
||||
if (params.id !== String(agent.public_slug)) {
|
||||
@@ -129,6 +133,9 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => {
|
||||
profileCommentCount,
|
||||
buildCount,
|
||||
guideCount,
|
||||
ownedBuildRows,
|
||||
ownedGuideRows,
|
||||
ownedNewsRows,
|
||||
] = await Promise.all([
|
||||
(
|
||||
await database.prepare(`SELECT roles.name FROM user_roles
|
||||
@@ -216,6 +223,28 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => {
|
||||
await database.prepare(`SELECT COUNT(*) AS count FROM articles
|
||||
WHERE kind='guide' AND owner_id=? AND visibility='public' AND deleted_at IS NULL`)
|
||||
).get(agentId),
|
||||
isSelf
|
||||
? (
|
||||
await database.prepare(`SELECT id,slug,title,visibility,updated_at
|
||||
FROM builds WHERE owner_id=? ORDER BY updated_at DESC`)
|
||||
).all(agentId)
|
||||
: [],
|
||||
isSelf
|
||||
? (
|
||||
await database.prepare(`SELECT articles.id,articles.slug,draft.title,articles.visibility,articles.updated_at
|
||||
FROM articles JOIN article_drafts draft ON draft.article_id=articles.id
|
||||
WHERE articles.kind='guide' AND articles.owner_id=? AND articles.deleted_at IS NULL
|
||||
ORDER BY articles.updated_at DESC`)
|
||||
).all(agentId)
|
||||
: [],
|
||||
canManageNews
|
||||
? (
|
||||
await database.prepare(`SELECT articles.id,articles.slug,draft.title,articles.visibility,articles.updated_at
|
||||
FROM articles JOIN article_drafts draft ON draft.article_id=articles.id
|
||||
WHERE articles.kind='news' AND articles.owner_id=? AND articles.deleted_at IS NULL
|
||||
ORDER BY articles.updated_at DESC`)
|
||||
).all(agentId)
|
||||
: [],
|
||||
]);
|
||||
|
||||
const loadedCommentIds = new Set(
|
||||
@@ -401,6 +430,32 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => {
|
||||
title: String(guide.title),
|
||||
updatedAt: String(guide.updated_at),
|
||||
})),
|
||||
ownedContent: isSelf
|
||||
? {
|
||||
builds: ownedBuildRows.map((build) => ({
|
||||
id: String(build.id),
|
||||
slug: String(build.slug),
|
||||
title: String(build.title),
|
||||
visibility: String(build.visibility),
|
||||
updatedAt: String(build.updated_at),
|
||||
})),
|
||||
guides: ownedGuideRows.map((guide) => ({
|
||||
id: String(guide.id),
|
||||
slug: String(guide.slug),
|
||||
title: String(guide.title),
|
||||
visibility: String(guide.visibility),
|
||||
updatedAt: String(guide.updated_at),
|
||||
})),
|
||||
news: ownedNewsRows.map((news) => ({
|
||||
id: String(news.id),
|
||||
slug: String(news.slug),
|
||||
title: String(news.title),
|
||||
visibility: String(news.visibility),
|
||||
updatedAt: String(news.updated_at),
|
||||
})),
|
||||
canManageNews,
|
||||
}
|
||||
: null,
|
||||
authoredComments: authoredCommentRows.map((comment) => {
|
||||
const targetType = String(comment.target_type);
|
||||
const targetId = String(comment.target_id);
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
type OwnedContent = PublicContent & { visibility: string };
|
||||
type AuthoredComment = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
@@ -60,6 +61,12 @@
|
||||
counts: { builds: number; guides: number; authoredComments: number; profileComments: number };
|
||||
builds: PublicContent[];
|
||||
guides: PublicContent[];
|
||||
ownedContent: {
|
||||
builds: OwnedContent[];
|
||||
guides: OwnedContent[];
|
||||
news: OwnedContent[];
|
||||
canManageNews: boolean;
|
||||
} | null;
|
||||
authoredComments: AuthoredComment[];
|
||||
profileComments: ProfileComment[];
|
||||
viewer: { id: string | null; canComment: boolean; canReportProfile: boolean; isSelf: boolean };
|
||||
@@ -86,6 +93,7 @@
|
||||
? 'DATE UNKNOWN'
|
||||
: date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }).toUpperCase();
|
||||
};
|
||||
const visibilityLabel = (visibility: string) => visibility.toUpperCase().replace('_', ' ');
|
||||
|
||||
$effect(() => {
|
||||
const token = String(form?.profileCommentResultToken ?? form?.commentResultToken ?? '');
|
||||
@@ -242,6 +250,44 @@
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if data.viewer.isSelf && data.ownedContent}
|
||||
<section class="profile-section content-control">
|
||||
<header>
|
||||
<div><p class="eyebrow">OWNER WORKSPACE</p><h2>CONTENT CONTROL</h2></div>
|
||||
<span>PRIVATE TO YOU</span>
|
||||
</header>
|
||||
<p class="content-control-intro">Open any authored item to edit it or change its visibility. Private and unlisted items appear only here.</p>
|
||||
<div class="content-control-grid">
|
||||
<section>
|
||||
<header><span>LOADOUTS</span><a href="/builder">NEW BUILD +</a></header>
|
||||
<div class="content-control-list">
|
||||
{#each data.ownedContent.builds as build}
|
||||
<a href={`/builder?build=${encodeURIComponent(build.slug)}`}><span><b>{build.title}</b><small>UPDATED {dateLabel(build.updatedAt)}</small></span><em>{visibilityLabel(build.visibility)}</em><strong>EDIT →</strong></a>
|
||||
{:else}<p class="content-control-empty">No builds yet. Create one in the Loadout Lab.</p>{/each}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<header><span>GUIDES</span><a href="/guides/new">NEW GUIDE +</a></header>
|
||||
<div class="content-control-list">
|
||||
{#each data.ownedContent.guides as guide}
|
||||
<a href={`/guides/${encodeURIComponent(guide.slug)}/edit`}><span><b>{guide.title}</b><small>UPDATED {dateLabel(guide.updatedAt)}</small></span><em>{visibilityLabel(guide.visibility)}</em><strong>EDIT →</strong></a>
|
||||
{:else}<p class="content-control-empty">No Guides yet. Start a field guide when you are ready.</p>{/each}
|
||||
</div>
|
||||
</section>
|
||||
{#if data.ownedContent.canManageNews}
|
||||
<section>
|
||||
<header><span>NEWS POSTS</span><a href="/news/new">NEW ARTICLE +</a></header>
|
||||
<div class="content-control-list">
|
||||
{#each data.ownedContent.news as news}
|
||||
<a href={`/news/${encodeURIComponent(news.slug)}/edit`}><span><b>{news.title}</b><small>UPDATED {dateLabel(news.updatedAt)}</small></span><em>{visibilityLabel(news.visibility)}</em><strong>EDIT →</strong></a>
|
||||
{:else}<p class="content-control-empty">No news posts authored by this Agent.</p>{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<section class="profile-comms" id="profile-comments">
|
||||
@@ -392,6 +438,7 @@
|
||||
.profile-section{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}.profile-section>header{min-height:54px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 12px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.profile-section>header>div{display:grid;gap:2px}.profile-section>header :is(h1,h2,p){margin:0}.profile-section>header h1,.profile-section>header h2{font-size:var(--shd-type-body);letter-spacing:.4px}.profile-section>header>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
|
||||
.bio-content{min-height:110px;padding:18px}:global(form.profile-bio-form){display:grid;gap:10px;padding:16px}.profile-bio-actions{display:flex;justify-content:flex-end;gap:7px}.profile-bio-actions button{min-height:40px}.profile-owner-notice{display:grid;gap:4px;margin:12px 16px 0;padding:10px;border:1px solid #745b2b;background:#1c170e;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.profile-owner-notice strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.7px}.profile-owner-notice.error{border-color:#70423b;background:#211414}.profile-owner-notice.error strong{color:var(--shd-danger)}.owner-profile-result{margin:10px 16px 0}.public-content-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;padding:7px}
|
||||
.public-content-card{min-width:0;display:grid;grid-template-rows:auto auto minmax(48px,1fr) auto;gap:6px;padding:12px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-orange);background:#0d1011;color:var(--shd-text)}.public-content-card>span,.public-content-card footer{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.public-content-card>span{color:var(--shd-orange)}.public-content-card h3,.public-content-card p{margin:0}.public-content-card h3{font-size:1.05rem}.public-content-card p{color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting);line-height:1.45}.public-content-card footer{display:flex;justify-content:space-between;gap:8px;padding-top:8px;border-top:1px solid var(--shd-border-soft)}.public-content-card small{color:var(--shd-text-dim)}.public-content-card b{color:var(--shd-orange)}.public-content-card:hover,.public-content-card:focus-visible{border-color:var(--shd-orange);background:var(--shd-surface-active)}
|
||||
.content-control{margin-top:10px}.content-control-intro{margin:0;padding:10px 12px;border-bottom:1px solid var(--shd-border);color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting)}.content-control-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px;padding:7px}.content-control-grid>section{min-width:0;border:1px solid var(--shd-border);background:#0d1011}.content-control-grid>section>header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.content-control-grid>section>header :is(span,a){font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.content-control-grid>section>header span{color:var(--shd-text-muted)}.content-control-grid>section>header a,.content-control-list strong{color:var(--shd-orange)}.content-control-list{display:grid}.content-control-list>a{min-width:0;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px;padding:10px;border-top:1px solid var(--shd-border);color:var(--shd-text)}.content-control-list>a:first-child{border-top:0}.content-control-list>a>span{min-width:0;display:grid;gap:3px}.content-control-list b{overflow:hidden;font-size:var(--shd-type-supporting);text-overflow:ellipsis;white-space:nowrap}.content-control-list small,.content-control-list em,.content-control-list strong{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.65px}.content-control-list small{color:var(--shd-text-dim)}.content-control-list em{justify-self:start;padding:2px 5px;border:1px solid var(--shd-border-strong);color:var(--shd-text-muted);font-style:normal}.content-control-list strong{grid-column:2;grid-row:1 / span 2;align-self:center}.content-control-list>a:hover,.content-control-list>a:focus-visible{background:var(--shd-surface-active);outline:1px solid var(--shd-orange);outline-offset:-1px}.content-control-empty{min-height:92px;display:grid;place-content:center;margin:0;padding:12px;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting);text-align:center}
|
||||
.public-empty{grid-column:1/-1;min-height:150px;display:grid;place-content:center;gap:6px;padding:20px;text-align:center}.public-empty span{color:var(--shd-orange);font-size:28px}.public-empty strong{font-size:var(--shd-type-telemetry);letter-spacing:1px}.public-empty p{margin:0;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting)}.public-empty.compact{min-height:110px}
|
||||
.authored-comment-list{display:grid;gap:7px;padding:7px}.authored-comment-list>article{display:grid;gap:10px;padding:11px;border:1px solid var(--shd-border);background:#0d1011}.authored-comment-list article>header{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:10px;padding:0;border:0;background:transparent}.authored-comment-list article>header>div{min-width:0;display:grid;align-content:center;gap:2px}.authored-comment-list article header span,.authored-comment-list article header small,.authored-comment-list article>a{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.authored-comment-list article header span,.authored-comment-list article>a{color:var(--shd-orange)}.authored-comment-list article header small{align-self:center;color:var(--shd-text-muted);text-align:right;white-space:nowrap}.authored-comment-list article>a{justify-self:end}
|
||||
.profile-comms{margin-top:16px;padding:0;background:transparent}.profile-comms-heading{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:flex;align-items:end;justify-content:space-between;gap:16px;padding:0 0 12px;border:0;border-bottom:1px solid var(--shd-border);background:transparent}.profile-comms-heading>div{display:grid;gap:3px}.profile-comms-heading :is(h2,p){margin:0}.profile-comms-heading h2{font-size:1.25rem}.profile-comms-heading>span{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.profile-comment-start{width:min(920px,100%);padding-top:16px}.profile-comment-trigger{width:100%;min-height:48px;padding:0 14px;border:1px solid var(--shd-border-strong);background:transparent;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-body);text-align:left;cursor:pointer}.profile-comment-trigger:hover,.profile-comment-trigger:focus-visible,.profile-comment-trigger[aria-expanded=true]{border-color:var(--shd-orange);color:var(--shd-text);background:var(--shd-surface-active)}:global(form.profile-comment-form){width:min(920px,100%);display:grid;gap:9px;padding:14px 0 0;border:0;background:transparent}.profile-comment-form-actions{display:flex;justify-content:flex-end;gap:7px}.profile-comment-form-actions button{min-height:38px}.profile-comment-list{width:min(980px,100%);display:grid;margin-top:26px;overflow:visible}.profile-comment-list>article{--thread-depth:0;position:relative;display:grid;gap:8px;margin:18px 0 0 calc(var(--thread-depth) * 22px);padding:0;scroll-margin-top:96px;background:transparent}.profile-comment-list>article:target{outline:1px solid var(--shd-orange);outline-offset:8px}.profile-comment-list>article.reply{padding-left:14px;border-left:1px solid var(--shd-border-strong)}.profile-comment-list article>header{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:flex;align-items:center;justify-content:flex-start;gap:12px;padding:0;border:0;background:transparent}.profile-comment-list article>header :global(.agent-identity){flex:0 1 auto}.profile-comment-list article :global(.markdown-content){max-width:75ch;margin-left:47px}.comment-actions{display:flex;align-items:center;gap:14px;min-height:32px;margin-left:47px}.comment-reply{min-height:32px;padding:0;border:0;background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px;cursor:pointer}.comment-reply:hover,.comment-reply:focus-visible,.comment-reply[aria-expanded=true]{color:var(--shd-orange);text-decoration:underline;text-underline-offset:3px}.reply-composer{margin:6px 0 0 47px}.reply-composer :global(form.profile-comment-form){padding-top:0}.removed-message{display:grid;gap:3px;padding:4px 0}.removed-message strong{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.removed-message span{color:var(--shd-text-dim);font-size:var(--shd-type-supporting);font-style:italic}.comment-delete-confirm{width:min(520px,calc(100% - 47px));display:grid;gap:8px;margin-left:47px;padding:10px;border:1px solid var(--shd-danger);background:#1b1111}.comment-delete-confirm p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-delete-confirm>div{display:flex;justify-content:flex-end;gap:7px}.comment-delete-confirm button{min-height:38px;padding:0 10px}.comment-access-note{width:min(920px,100%);display:grid;gap:7px;padding:16px 0 0}.comment-access-note strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.comment-access-note p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-access-note a{justify-self:start}.profile-comms .public-empty{min-height:100px;place-content:start;padding:24px 0;text-align:left}.profile-comms .public-empty span{display:none}
|
||||
@@ -399,6 +446,8 @@
|
||||
.profile-comment-list article :global(.markdown-content),.comment-actions{margin-left:53px}.reply-composer{margin-left:53px}.comment-delete-confirm{width:min(520px,calc(100% - 53px));margin-left:53px}
|
||||
.pinned-comment-label{margin-left:auto;color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.7px}
|
||||
@media(max-width:1000px){.agent-profile-grid{grid-template-columns:1fr}.agent-main-column{grid-column:1}.agent-activity{position:static;grid-column:1;grid-template-columns:repeat(2,minmax(0,1fr))}.agent-activity .profile-section:last-child{grid-column:1/-1}.agent-telemetry{grid-template-columns:repeat(2,minmax(0,1fr))}.agent-telemetry>div:nth-child(3){border-left:0}.agent-telemetry>div:nth-child(n+3){border-top:1px solid var(--shd-border)}}
|
||||
@media(max-width:1000px){.content-control-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@media(max-width:650px){.content-control-grid{grid-template-columns:1fr}}
|
||||
@media(max-width:650px){.agent-command{grid-template-columns:1fr;align-items:stretch}.agent-command-actions{width:100%;display:grid;grid-template-columns:minmax(0,1fr) auto}.agent-command-actions :global(.profile-visibility-form),.agent-command-actions .visibility-toggle{width:100%}.agent-command-actions .visibility-toggle{min-height:44px;justify-content:center}.agent-command-actions :global(.content-report){grid-column:1/-1}.agent-command-actions :global(.content-report summary){width:100%;justify-content:center}.mobile-account-jump{grid-column:1/-1;min-height:44px;display:flex;align-items:center;justify-content:center;border:1px solid var(--shd-border-strong);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.mobile-account-jump:hover,.mobile-account-jump:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange);background:var(--shd-surface-active)}.agent-main-column{display:contents}.agent-primary{grid-column:1;grid-row:1}.agent-activity{grid-column:1;grid-row:2;grid-template-columns:1fr}.profile-comms{grid-column:1;grid-row:3;margin-top:16px}.agent-activity .profile-section:last-child{grid-column:auto}.agent-published-grid{grid-template-columns:1fr}.public-content-grid{grid-template-columns:1fr}.profile-section>header,.profile-comms-heading{align-items:flex-start;flex-direction:column}.agent-bio>header{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start}.agent-bio .profile-header-actions{width:auto}.bio-edit-trigger{min-height:44px;padding:0 5px;border-color:transparent}.bio-edit-trigger:hover,.bio-edit-trigger:focus-visible,.bio-edit-trigger[aria-expanded=true]{border-color:var(--shd-border-strong)}.profile-bio-actions{display:grid;grid-template-columns:1fr 1fr}.profile-bio-actions button{min-height:44px}.profile-comment-trigger,.profile-comment-form-actions button,.comment-reply,.comment-delete-confirm button{min-height:44px}.profile-comment-form-actions{display:grid;grid-template-columns:1fr 1fr}.profile-comment-list>article{margin-left:calc(var(--thread-depth) * 12px)}.comment-delete-confirm>div{display:grid;grid-template-columns:1fr 1fr}}
|
||||
@media(prefers-reduced-motion:reduce){.public-content-card,.profile-comment-trigger,.comment-reply{transition:none}}
|
||||
</style>
|
||||
|
||||
@@ -60,7 +60,10 @@ export const POST: RequestHandler = async ({ cookies, request, url }) => {
|
||||
}
|
||||
try {
|
||||
const authoringRequest = parseAiCalculationAuthoringRequest(payload);
|
||||
const release = reserveAiMechanicGeneration(operator.id);
|
||||
const release = reserveAiMechanicGeneration(
|
||||
operator.id,
|
||||
operator.role === 'admin',
|
||||
);
|
||||
let response;
|
||||
try {
|
||||
response = await generateAiCalculationDraft(
|
||||
|
||||
@@ -58,7 +58,10 @@ export const POST: RequestHandler = async ({ cookies, request, url }) => {
|
||||
}
|
||||
try {
|
||||
const authoringRequest = parseAiMechanicAuthoringRequest(payload);
|
||||
const release = reserveAiMechanicGeneration(operator.id);
|
||||
const release = reserveAiMechanicGeneration(
|
||||
operator.id,
|
||||
operator.role === 'admin',
|
||||
);
|
||||
let response;
|
||||
try {
|
||||
response = await generateAiMechanicDraft(authoringRequest, operator.id);
|
||||
|
||||
@@ -3,10 +3,11 @@ import { gameDataIconUrl } from '$lib/server/game-data-icons';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { parseLoadoutDraft } from '$lib/builder/loadout';
|
||||
import { parseLoadoutDraft, type LoadoutDraft } from '$lib/builder/loadout';
|
||||
import { publicOrigin } from '$lib/server/public-origin';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
|
||||
import { hydrateSkillChipProfileRows } from '$lib/skill-chips/profiles';
|
||||
|
||||
const validVisibility = new Set(['private', 'unlisted', 'public']);
|
||||
const slugify = (value: string) =>
|
||||
@@ -28,6 +29,102 @@ 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,
|
||||
focusAbilities,
|
||||
primaryOptions,
|
||||
primaryValues,
|
||||
secondaryOptions,
|
||||
secondaryValues,
|
||||
] = await Promise.all([
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT id,specialization_ability_id FROM skill_chip_sets WHERE active=1',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT ability_id,ability_slot FROM specialization_focus_abilities WHERE focus_id=? ORDER BY 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,
|
||||
);
|
||||
if (
|
||||
!definition ||
|
||||
!expectedAbility ||
|
||||
String(definition.specialization_ability_id) !==
|
||||
String(expectedAbility.ability_id)
|
||||
)
|
||||
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;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const database = await getDatabase();
|
||||
const viewer = await getSessionUser(cookies);
|
||||
@@ -70,8 +167,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
talentTemplates,
|
||||
chipSets,
|
||||
chipTwoPiece,
|
||||
chipPrimary,
|
||||
chipSecondary,
|
||||
chipPrimaryOptions,
|
||||
chipPrimaryQualityValues,
|
||||
chipSecondaryOptions,
|
||||
chipSecondaryQualityValues,
|
||||
chipTalents,
|
||||
chipTalentEffects,
|
||||
protocols,
|
||||
@@ -162,9 +261,25 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
(
|
||||
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM skill_chip_secondary_options')
|
||||
await database.prepare(
|
||||
'SELECT stat_attribute_id FROM skill_chip_primary_options ORDER BY stat_attribute_id',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT stat_attribute_id,quality,min_value,max_value FROM skill_chip_primary_quality_values ORDER BY stat_attribute_id,quality',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT stat_attribute_id FROM skill_chip_secondary_options ORDER BY stat_attribute_id',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT stat_attribute_id,quality,min_value,max_value FROM skill_chip_secondary_quality_values ORDER BY stat_attribute_id,quality',
|
||||
)
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
|
||||
(
|
||||
@@ -329,6 +444,16 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
weaponBonuses,
|
||||
),
|
||||
talents: configuredTalents,
|
||||
skillChipProfile: {
|
||||
primaryOptions: hydrateSkillChipProfileRows(
|
||||
chipPrimaryOptions,
|
||||
chipPrimaryQualityValues,
|
||||
),
|
||||
secondaryOptions: hydrateSkillChipProfileRows(
|
||||
chipSecondaryOptions,
|
||||
chipSecondaryQualityValues,
|
||||
),
|
||||
},
|
||||
chipSets: chipSets.map((set) => {
|
||||
const talent = chipTalents.find(
|
||||
(item) => item.skill_chip_set_id === set.id,
|
||||
@@ -339,12 +464,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
twoPiece:
|
||||
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
|
||||
null,
|
||||
primaryStatIds: chipPrimary
|
||||
.filter((item) => item.skill_chip_set_id === set.id)
|
||||
.map((item) => String(item.stat_attribute_id)),
|
||||
secondaryStatIds: chipSecondary
|
||||
.filter((item) => item.skill_chip_set_id === set.id)
|
||||
.map((item) => String(item.stat_attribute_id)),
|
||||
talent: talent
|
||||
? {
|
||||
...talent,
|
||||
@@ -473,6 +592,8 @@ export const actions: Actions = {
|
||||
return fail(400, { buildError: 'Choose a valid build visibility.' });
|
||||
|
||||
const database = await getDatabase();
|
||||
const skillChipError = await validateSkillChipSelections(database, loadout);
|
||||
if (skillChipError) return fail(400, { buildError: skillChipError });
|
||||
const now = new Date().toISOString();
|
||||
let buildId = id;
|
||||
let slug = '';
|
||||
|
||||
+110
-25
@@ -15,8 +15,8 @@
|
||||
import { summarizeLoadout } from '$lib/builder/loadout-summary';
|
||||
import { toast } from '$lib/toast';
|
||||
import {
|
||||
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, weaponBaseRange,
|
||||
type LoadoutDraft, type Roll
|
||||
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, skillChipQualities, skillChipSecondaryCapacity, weaponBaseRange,
|
||||
type LoadoutDraft, type Roll, type SkillChipQuality
|
||||
} from '$lib/builder/loadout';
|
||||
|
||||
let { data, form } = $props();
|
||||
@@ -35,8 +35,23 @@
|
||||
const coreKeys = ['firepower','toughness','engineering'] as const;
|
||||
const gearAttributes = $derived(data.attributes.filter((item) => item.gear_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
|
||||
const weaponAttributes = $derived(data.attributes.filter((item) => item.weapon_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
|
||||
const chipAttributes = $derived(data.attributes.filter((item) => item.skill_chip_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
|
||||
const attribute = (id: string) => data.attributes.find((item) => item.id === id);
|
||||
const chipPrimaryAttributes = $derived(data.skillChipProfile.primaryOptions.map((option: any) => attribute(option.statId)).filter(Boolean).map((item: any) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
|
||||
const chipSecondaryAttributes = $derived(data.skillChipProfile.secondaryOptions.map((option: any) => attribute(option.statId)).filter(Boolean).map((item: any) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
|
||||
const chipRange = (kind: 'primary' | 'secondary', statId: string, quality: SkillChipQuality) =>
|
||||
data.skillChipProfile[`${kind}Options`].find((option: any) => option.statId === statId)?.ranges?.[quality] ?? null;
|
||||
type ChipRange = { min: number; max: number };
|
||||
const validChipRange = (range: any): range is ChipRange => Number.isFinite(Number(range?.min)) && Number.isFinite(Number(range?.max)) && Number(range.max) >= Number(range.min);
|
||||
const chipRangeMin = (kind: 'primary' | 'secondary', statId: string, quality: SkillChipQuality) => Number(chipRange(kind, statId, quality)?.min ?? 0);
|
||||
const chipRangeMax = (kind: 'primary' | 'secondary', statId: string, quality: SkillChipQuality) => Number(chipRange(kind, statId, quality)?.max ?? 0);
|
||||
const chipProfileConfigured = $derived(
|
||||
data.skillChipProfile.primaryOptions.length === 4
|
||||
&& chipPrimaryAttributes.length === data.skillChipProfile.primaryOptions.length
|
||||
&& data.skillChipProfile.primaryOptions.every((option: any) => skillChipQualities.every((quality) => validChipRange(option.ranges?.[quality])))
|
||||
&& data.skillChipProfile.secondaryOptions.length >= 2
|
||||
&& chipSecondaryAttributes.length === data.skillChipProfile.secondaryOptions.length
|
||||
&& data.skillChipProfile.secondaryOptions.every((option: any) => ['Specialized','Superior','High-end'].every((quality) => validChipRange(option.ranges?.[quality])))
|
||||
);
|
||||
const gearItem = (slot: string) => data.gear.find((item) => item.id === draft.gear[slot].itemId);
|
||||
const weaponItem = (index: number) => data.weapons.find((item) => item.id === draft.weapons[index].itemId);
|
||||
const chipItem = (index: number) => data.chipSets.find((item) => item.id === draft.chips[index].setId);
|
||||
@@ -80,13 +95,35 @@
|
||||
coreKeys.reduce((total, key) => total + Number(selection[key] ?? 0), 0);
|
||||
const attributeRange = (stat: any, source: 'gear' | 'weapon', level: number) =>
|
||||
scaledRange(stat?.[`${source}_min`], stat?.[`${source}_max`], level, stat?.[`${source}_reference_level`], source);
|
||||
const setRollStat = (roll: Roll, id: string, source: 'gear' | 'weapon' | 'chip', level = 50) => {
|
||||
const setRollStat = (roll: Roll, id: string, source: 'gear' | 'weapon', level = 50) => {
|
||||
roll.statId = id;
|
||||
const stat = attribute(id);
|
||||
const min = source === 'chip'
|
||||
? stat?.skill_chip_min
|
||||
: attributeRange(stat, source, level).min;
|
||||
roll.value = Number(min ?? 0);
|
||||
roll.value = Number(attributeRange(stat, source, level).min ?? 0);
|
||||
};
|
||||
const setChipRoll = (roll: Roll, id: string, kind: 'primary' | 'secondary', quality: SkillChipQuality) => {
|
||||
roll.statId = id;
|
||||
const range = chipRange(kind, id, quality);
|
||||
roll.value = validChipRange(range) ? Number(range.min) : 0;
|
||||
};
|
||||
const chipRollConfigured = (roll: Roll, kind: 'primary' | 'secondary', quality: SkillChipQuality) => {
|
||||
const options = data.skillChipProfile[`${kind}Options`];
|
||||
const range = chipRange(kind, roll.statId, quality);
|
||||
const value = Number(roll.value);
|
||||
return options.some((option: any) => option.statId === roll.statId)
|
||||
&& validChipRange(range)
|
||||
&& Number.isFinite(value)
|
||||
&& value >= Number(range.min)
|
||||
&& value <= Number(range.max);
|
||||
};
|
||||
const chipSelectionConfigured = (chip: LoadoutDraft['chips'][number]) => {
|
||||
const capacity = skillChipSecondaryCapacity[chip.quality];
|
||||
const secondaryIds = chip.secondary.map((roll) => roll.statId);
|
||||
return Boolean(data.chipSets.some((item: any) => item.id === chip.setId))
|
||||
&& chipProfileConfigured
|
||||
&& chipRollConfigured(chip.primary, 'primary', chip.quality)
|
||||
&& chip.secondary.length === capacity
|
||||
&& new Set(secondaryIds).size === secondaryIds.length
|
||||
&& chip.secondary.every((roll) => chipRollConfigured(roll, 'secondary', chip.quality));
|
||||
};
|
||||
const rankPointCost = (rank: number) => Number(data.specializationProgression.rankProfiles.find((profile: any) => Number(profile.rank) === rank)?.point_cost ?? rank);
|
||||
const selectedSpecializationPoints = $derived(selectedFocus?.nodes.reduce((total: number, node: any) => {
|
||||
@@ -222,15 +259,41 @@
|
||||
selection.rolls = [blankRoll(), blankRoll()];
|
||||
};
|
||||
const configureChip = (index: number, id: string) => {
|
||||
const chip = draft.chips[index], item = data.chipSets.find((entry) => entry.id === id);
|
||||
const chip = draft.chips[index];
|
||||
chip.setId = id;
|
||||
chip.primary = { statId: item?.primaryStatIds[0] ?? '', value: 0 };
|
||||
chip.secondary = [
|
||||
{ statId: item?.secondaryStatIds[0] ?? '', value: 0 },
|
||||
{ statId: item?.secondaryStatIds[1] ?? '', value: 0 }
|
||||
];
|
||||
if (chip.primary.statId) setRollStat(chip.primary, chip.primary.statId, 'chip');
|
||||
for (const roll of chip.secondary) if (roll.statId) setRollStat(roll, roll.statId, 'chip');
|
||||
chip.primary = blankRoll();
|
||||
chip.secondary = [];
|
||||
changeChipQuality(index, chip.quality);
|
||||
};
|
||||
const changeChipQuality = (index: number, quality: SkillChipQuality) => {
|
||||
if (!skillChipQualities.includes(quality)) return;
|
||||
const chip = draft.chips[index];
|
||||
chip.quality = quality;
|
||||
const primaryOption = data.skillChipProfile.primaryOptions.find((option: any) => option.statId === chip.primary.statId && validChipRange(option.ranges?.[quality]))
|
||||
?? data.skillChipProfile.primaryOptions.find((option: any) => validChipRange(option.ranges?.[quality]));
|
||||
if (primaryOption) {
|
||||
const range = primaryOption.ranges[quality];
|
||||
chip.primary = {
|
||||
statId: primaryOption.statId,
|
||||
value: clamp(chip.primary.statId === primaryOption.statId ? chip.primary.value : range.min, range.min, range.max)
|
||||
};
|
||||
} else chip.primary = blankRoll();
|
||||
const capacity = skillChipSecondaryCapacity[quality];
|
||||
const next: Roll[] = [];
|
||||
for (const roll of chip.secondary) {
|
||||
if (next.length >= capacity || next.some((entry) => entry.statId === roll.statId)) continue;
|
||||
const range = chipRange('secondary', roll.statId, quality);
|
||||
if (!validChipRange(range)) continue;
|
||||
next.push({ statId: roll.statId, value: clamp(roll.value, range.min, range.max) });
|
||||
}
|
||||
for (const option of data.skillChipProfile.secondaryOptions) {
|
||||
if (next.length >= capacity) break;
|
||||
if (next.some((entry) => entry.statId === option.statId)) continue;
|
||||
const range = option.ranges?.[quality];
|
||||
if (validChipRange(range)) next.push({ statId: option.statId, value: Number(range.min) });
|
||||
}
|
||||
while (next.length < capacity) next.push(blankRoll());
|
||||
chip.secondary = next;
|
||||
};
|
||||
const changeWeaponTier = (index: number, tier: number) => {
|
||||
const selection = draft.weapons[index], item = weaponItem(index);
|
||||
@@ -297,7 +360,8 @@
|
||||
});
|
||||
const abilityCells = $derived.by((): MatrixCell[] => [0, 1, 2].map((index) => {
|
||||
const ability = selectedFocus?.abilities[index];
|
||||
const configuredChips = draft.chips.slice(index * 3, index * 3 + 3).filter((chip) => chip.setId);
|
||||
const abilityChips = draft.chips.slice(index * 3, index * 3 + 3);
|
||||
const configuredChips = abilityChips.filter((chip) => chip.setId);
|
||||
const chipNames = configuredChips.map((chip) => data.chipSets.find((item) => item.id === chip.setId)?.name).filter(Boolean);
|
||||
return {
|
||||
id: `ability:${index}`,
|
||||
@@ -306,7 +370,7 @@
|
||||
meta: ability?.attack?.damage_type ?? '',
|
||||
summary: chipNames.length ? chipNames.join(' · ') : `${configuredChips.length} / 3 SKILL CHIPS`,
|
||||
iconUrl: ability?.iconUrl,
|
||||
issue: !ability ? 'ABILITY NOT CONFIGURED' : configuredChips.length < 3 ? `${3 - configuredChips.length} CHIP${configuredChips.length === 2 ? '' : 'S'} REQUIRED` : undefined
|
||||
issue: !ability ? 'ABILITY NOT CONFIGURED' : configuredChips.length < 3 ? `${3 - configuredChips.length} CHIP${configuredChips.length === 2 ? '' : 'S'} REQUIRED` : abilityChips.some((chip) => !chipSelectionConfigured(chip)) ? 'CONFIG REQUIRED' : undefined
|
||||
};
|
||||
}));
|
||||
const weaponCells = $derived.by((): MatrixCell[] => draft.weapons.map((selection, index) => {
|
||||
@@ -357,14 +421,15 @@
|
||||
&& selectedFocus.abilities.length === 3
|
||||
&& draft.weapons.every((item) => item.itemId)
|
||||
&& gearSlots.every((slot) => draft.gear[slot].itemId)
|
||||
&& draft.chips.every((item) => item.setId)
|
||||
&& draft.chips.every(chipSelectionConfigured)
|
||||
&& draft.protocol.itemId
|
||||
));
|
||||
|
||||
onMount(() => {
|
||||
if (data.initialBuild) return;
|
||||
localStorage.removeItem('shd-build-loadout-draft-v3');
|
||||
const stored = localStorage.getItem('shd-build-loadout-draft-v4');
|
||||
localStorage.removeItem('shd-build-loadout-draft-v4');
|
||||
if (data.initialBuild) return;
|
||||
const stored = localStorage.getItem('shd-build-loadout-draft-v5');
|
||||
if (!stored) return;
|
||||
try {
|
||||
const parsed = parseLoadoutDraft(JSON.parse(stored));
|
||||
@@ -372,9 +437,9 @@
|
||||
draft = parsed;
|
||||
if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = '';
|
||||
saved = true;
|
||||
} else localStorage.removeItem('shd-build-loadout-draft-v4');
|
||||
} else localStorage.removeItem('shd-build-loadout-draft-v5');
|
||||
} catch {
|
||||
localStorage.removeItem('shd-build-loadout-draft-v4');
|
||||
localStorage.removeItem('shd-build-loadout-draft-v5');
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
@@ -392,7 +457,7 @@
|
||||
: 'Build published to the SHD archive.';
|
||||
});
|
||||
function saveDraft() {
|
||||
localStorage.setItem('shd-build-loadout-draft-v4', JSON.stringify(draft));
|
||||
localStorage.setItem('shd-build-loadout-draft-v5', JSON.stringify(draft));
|
||||
saved = true;
|
||||
toast.success('Draft saved locally', 'This loadout is available on this device.');
|
||||
}
|
||||
@@ -548,7 +613,27 @@
|
||||
<div class="loadout-editor-head"><div><span>ABILITY {abilityIndex + 1} · CHIP {(index % 3) + 1}</span><h2>{ability?.name?.toUpperCase() ?? 'SKILL CHIP'}</h2></div><b>{chip.quality.toUpperCase()}</b></div>
|
||||
<button class="builder-return-link" type="button" onclick={() => active = `ability:${abilityIndex}`}>← RETURN TO {ability?.name?.toUpperCase() ?? 'ABILITY'}</button>
|
||||
{#if ability}<BuilderPicker items={data.chipSets.filter((entry: any) => entry.specialization_ability_id === ability.id)} bind:value={chip.setId} onchange={(id) => configureChip(index,id)} label="ABILITY-COMPATIBLE SKILL CHIP SET"/>{:else}<p class="error">Select a focus with three configured class abilities before equipping Skill Chips.</p>{/if}
|
||||
{#if item}<div class="builder-field-grid"><label>QUALITY<select bind:value={chip.quality}><option>Superior</option><option>High-end</option></select></label></div><div class="builder-chip-rolls"><div><BuilderPicker items={chipAttributes.filter((entry) => item.primaryStatIds.includes(entry.id))} bind:value={chip.primary.statId} onchange={(id) => setRollStat(chip.primary,id,'chip')} label="PRIMARY ATTRIBUTE"/><label>VALUE<input type="number" min={attribute(chip.primary.statId)?.skill_chip_min ?? 0} max={attribute(chip.primary.statId)?.skill_chip_max ?? 0} bind:value={chip.primary.value} onblur={() => chip.primary.value = clamp(chip.primary.value,attribute(chip.primary.statId)?.skill_chip_min,attribute(chip.primary.statId)?.skill_chip_max)}/></label></div>{#each chip.secondary as roll,index}<div><BuilderPicker items={chipAttributes.filter((entry) => item.secondaryStatIds.includes(entry.id))} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'chip')} disabledIds={chip.secondary.filter((_,i) => i !== index).map((entry) => entry.statId)} label={`SECONDARY ATTRIBUTE ${index + 1}`}/><label>VALUE<input type="number" min={attribute(roll.statId)?.skill_chip_min ?? 0} max={attribute(roll.statId)?.skill_chip_max ?? 0} bind:value={roll.value} onblur={() => roll.value = clamp(roll.value,attribute(roll.statId)?.skill_chip_min,attribute(roll.statId)?.skill_chip_max)}/></label></div>{/each}</div>{/if}
|
||||
{#if !chipProfileConfigured}<p class="error">SHARED SKILL CHIP PROFILE · CONFIG REQUIRED. Complete every global quality range in ISAC before this loadout can be complete.</p>{/if}
|
||||
{#if item}
|
||||
<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>ABILITY-SCOPED PRIMARY</span><small>{ability?.name?.toUpperCase() ?? 'ASSIGNED ABILITY'} ONLY · EXCLUDED FROM KNOWN AGENT STATS</small></div><b>NOT YET DEFINED</b></div>
|
||||
<p class="builder-scaling-note">The primary roll belongs to this ability. Its calculation application is NOT YET DEFINED, so it does not contribute to current Agent 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 ATTRIBUTE"/>
|
||||
{#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 · CONFIG REQUIRED</p>{/if}
|
||||
</div>
|
||||
{#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 ATTRIBUTE ${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 · CONFIG REQUIRED</p>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if skillChipSecondaryCapacity[chip.quality] === 0}<p class="builder-scaling-note">STANDARD QUALITY · NO SECONDARY ATTRIBUTES</p>{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
{@const protocol = protocolItem()}
|
||||
<div class="loadout-editor-head"><div><span>AGENT SYSTEM</span><h2>OS PROTOCOL</h2></div><b>{protocol?.quality?.toUpperCase() ?? 'UNASSIGNED'}</b></div>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
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.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();
|
||||
});
|
||||
});
|
||||
@@ -64,7 +64,7 @@
|
||||
</section>
|
||||
<section>
|
||||
<header><span>AGENT SYSTEMS</span><b>10 SLOTS</b></header>
|
||||
{#each data.loadout.chips as selection, index}{@const item=chip(selection.setId)}<article><small>SKILL CHIP 0{index+1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? selection.quality : 'NOT CONFIGURED'}</span>{#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}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -40,6 +40,10 @@ import { recordIsacAudit } from '$lib/server/audit';
|
||||
import { removePublicComment } from '$lib/server/comment-removal';
|
||||
import { createNotification } from '$lib/server/notifications';
|
||||
import type { Actions } from './$types';
|
||||
import {
|
||||
publicWeaponModSlots,
|
||||
weaponModSlotDefinitionByName,
|
||||
} from '$lib/weapons/mod-slots';
|
||||
|
||||
const number = (value: unknown) => {
|
||||
const numeric = Number(value);
|
||||
@@ -336,8 +340,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
rowsSql = `SELECT item.*,render.id AS render_id,render.updated_at AS render_updated_at,
|
||||
native.damage_min,native.damage_max,native.reference_level,
|
||||
(SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=1) AS talent_1_count,
|
||||
(SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=2) AS talent_2_count,
|
||||
(SELECT COUNT(*) FROM weapon_category_attribute_bonuses bonuses WHERE bonuses.weapon_category=item.category) AS bonus_count
|
||||
(SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=2) AS talent_2_count
|
||||
FROM weapons item
|
||||
LEFT JOIN weapon_tier_ranges native ON native.weapon_id=item.id AND native.tier=item.native_tier
|
||||
LEFT JOIN managed_images render ON render.source_entity_type='weapon_render' AND render.source_entity_id=item.id
|
||||
@@ -386,7 +389,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
rowsSql = `SELECT item.*,weapons.name AS exotic_weapon,
|
||||
render.id AS render_id,render.updated_at AS render_updated_at,
|
||||
GROUP_CONCAT(DISTINCT categories.weapon_category) AS categories,
|
||||
(SELECT COUNT(*) FROM weapon_mod_secondary_options secondary WHERE secondary.weapon_mod_id=item.id) AS secondary_count,
|
||||
(SELECT COUNT(*) FROM weapon_mod_secondary_options) AS secondary_count,
|
||||
(SELECT COUNT(*) FROM weapon_mod_effects effects WHERE effects.weapon_mod_id=item.id) AS effect_count
|
||||
FROM weapon_mods item
|
||||
LEFT JOIN weapons ON weapons.id=item.exotic_weapon_id
|
||||
@@ -398,7 +401,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
case 'gear-mods':
|
||||
countSql = `SELECT COUNT(*) AS total FROM gear_mods item WHERE item.active=1${searchClause}`;
|
||||
rowsSql = `SELECT item.*,render.id AS render_id,render.updated_at AS render_updated_at,
|
||||
(SELECT COUNT(*) FROM gear_mod_secondary_options secondary WHERE secondary.gear_mod_id=item.id) AS secondary_count,
|
||||
(SELECT COUNT(*) FROM gear_mod_secondary_options) AS secondary_count,
|
||||
(SELECT MIN(primary_level_1_min) FROM gear_mod_quality_values quality WHERE quality.gear_mod_id=item.id) AS level_1_floor,
|
||||
(SELECT MAX(primary_level_20_max) FROM gear_mod_quality_values quality WHERE quality.gear_mod_id=item.id) AS level_20_ceiling
|
||||
FROM gear_mods item
|
||||
@@ -409,7 +412,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
case 'os-protocols':
|
||||
countSql = `SELECT COUNT(*) AS total FROM os_protocols item WHERE item.active=1${searchClause}`;
|
||||
rowsSql = `SELECT item.*,attributes.name AS primary_stat,attributes.unit AS primary_unit,
|
||||
primary_effect.base_value,talents.name AS talent_name,icons.updated_at AS icon_updated_at,
|
||||
primary_effect.base_value,talents.name AS talent_name,talents.description_template AS talent_description,icons.updated_at AS icon_updated_at,
|
||||
render.id AS render_id,render.updated_at AS render_updated_at
|
||||
FROM os_protocols item
|
||||
LEFT JOIN os_protocol_primary_effects primary_effect ON primary_effect.protocol_id=item.id
|
||||
@@ -500,8 +503,8 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE item.active=1${searchClause}`;
|
||||
rowsSql = `SELECT item.*,bonus.min_value,bonus.max_value,attributes.name AS bonus_stat,attributes.unit AS bonus_unit,
|
||||
talents.name AS talent_name,abilities.name AS ability_name,specializations.name AS specialization_name,
|
||||
(SELECT COUNT(*) FROM skill_chip_primary_options options WHERE options.skill_chip_set_id=item.id) AS primary_count,
|
||||
(SELECT COUNT(*) FROM skill_chip_secondary_options options WHERE options.skill_chip_set_id=item.id) AS secondary_count,
|
||||
(SELECT COUNT(*) FROM skill_chip_primary_options) AS primary_count,
|
||||
(SELECT COUNT(*) FROM skill_chip_secondary_options) AS secondary_count,
|
||||
icons.updated_at AS icon_updated_at,render.id AS render_id,render.updated_at AS render_updated_at
|
||||
FROM skill_chip_sets item
|
||||
LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id
|
||||
@@ -549,6 +552,17 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
const rows = await (
|
||||
await database.prepare(rowsSql)
|
||||
).all(...queryArgs, pagination.perPage, pagination.offset);
|
||||
const protocolTalentEffects =
|
||||
category.slug === 'os-protocols' && rows.length
|
||||
? await (
|
||||
await database.prepare(`SELECT talents.protocol_id,effects.base_value,attributes.unit
|
||||
FROM os_protocol_talents talents
|
||||
JOIN os_protocol_talent_effects effects ON effects.talent_id=talents.id
|
||||
JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id
|
||||
WHERE talents.protocol_id IN (${rows.map(() => '?').join(',')})
|
||||
ORDER BY talents.protocol_id,effects.sort_order`)
|
||||
).all(...rows.map((row) => row.id))
|
||||
: [];
|
||||
const weaponRelations =
|
||||
category.slug === 'weapons' && rows.length
|
||||
? await (
|
||||
@@ -563,6 +577,22 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
ORDER BY options.talent_slot,talents.name COLLATE NOCASE`)
|
||||
).all(...rows.map((row) => row.id))
|
||||
: [];
|
||||
const weaponModSlotRows =
|
||||
category.slug === 'weapons' && rows.length
|
||||
? await (
|
||||
await database.prepare(`SELECT weapon_id,tier,slot_type FROM weapon_mod_slots
|
||||
WHERE weapon_id IN (${rows.map(() => '?').join(',')})
|
||||
ORDER BY weapon_id,tier,slot_type`)
|
||||
).all(...rows.map((row) => row.id))
|
||||
: [];
|
||||
const weaponModSlotIconRows =
|
||||
category.slug === 'weapons'
|
||||
? await (
|
||||
await database.prepare(
|
||||
"SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='weapon_mod_slot'",
|
||||
)
|
||||
).all()
|
||||
: [];
|
||||
const gearRelations =
|
||||
category.slug === 'gear' && rows.length
|
||||
? await (
|
||||
@@ -769,6 +799,36 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
const relatedTalents = weaponRelations.filter(
|
||||
(relation) => relation.weapon_id === row.id,
|
||||
);
|
||||
const modSlots = publicWeaponModSlots(
|
||||
Number(row.native_tier),
|
||||
[0, 1, 2].map((tier) => ({
|
||||
tier,
|
||||
slots: weaponModSlotRows
|
||||
.filter(
|
||||
(slot) =>
|
||||
slot.weapon_id === row.id && Number(slot.tier) === tier,
|
||||
)
|
||||
.map((slot) => String(slot.slot_type)),
|
||||
})),
|
||||
).map((slot) => {
|
||||
const definition = weaponModSlotDefinitionByName(slot.slot);
|
||||
const icon = definition
|
||||
? weaponModSlotIconRows.find(
|
||||
(row) => row.entity_id === definition.id,
|
||||
)
|
||||
: null;
|
||||
return {
|
||||
...slot,
|
||||
iconUrl:
|
||||
definition && icon
|
||||
? gameDataIconUrl(
|
||||
'weapon_mod_slot',
|
||||
definition.id,
|
||||
icon.updated_at,
|
||||
)
|
||||
: '',
|
||||
};
|
||||
});
|
||||
const talentItems = (slot: number) =>
|
||||
relatedTalents
|
||||
.filter((relation) => Number(relation.talent_slot) === slot)
|
||||
@@ -801,11 +861,11 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
? managedImageUrl(String(row.render_id), row.render_updated_at)
|
||||
: '',
|
||||
gameIcon: weaponIcon(row.category),
|
||||
headerBadge: `T${row.native_tier}`,
|
||||
headerBadges: [
|
||||
`T${row.native_tier}`,
|
||||
...(Number(row.is_exotic) ? ['EXOTIC'] : []),
|
||||
],
|
||||
headerMetaIcon: weaponIcon(row.damage_type),
|
||||
badges: Number(row.is_exotic)
|
||||
? [{ label: 'EXOTIC', tone: 'exotic' }]
|
||||
: [],
|
||||
factLayout: 'stack' as const,
|
||||
facts: [
|
||||
{
|
||||
@@ -831,7 +891,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
],
|
||||
},
|
||||
],
|
||||
lines: [{ label: 'BASE BONUSES', value: String(row.bonus_count) }],
|
||||
weaponModSlots: modSlots,
|
||||
iconGroups: [
|
||||
{
|
||||
label: 'TALENT 1 POOL',
|
||||
@@ -914,28 +974,28 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
? managedImageUrl(String(row.render_id), row.render_updated_at)
|
||||
: '',
|
||||
iconUrl: iconUrl('os_protocol', row.id, row.icon_updated_at),
|
||||
badges: [
|
||||
{
|
||||
label: String(row.quality),
|
||||
tone: String(row.quality).toLowerCase().replace('-', ''),
|
||||
topBorderTone: String(row.quality).toLowerCase().replace('-', ''),
|
||||
headerBadge: String(row.quality).toUpperCase(),
|
||||
headerBadgeTone: String(row.quality).toLowerCase().replace('-', ''),
|
||||
factLayout: 'stack' as const,
|
||||
facts: [],
|
||||
protocolPreview: {
|
||||
primaryStat: String(row.primary_stat ?? 'NOT CONFIGURED'),
|
||||
primaryUnit: String(row.primary_unit ?? ''),
|
||||
primaryBaseValue: Number(row.base_value),
|
||||
talentDescription: row.talent_description
|
||||
? String(row.talent_description)
|
||||
: undefined,
|
||||
talentEffects: protocolTalentEffects
|
||||
.filter((effect) => effect.protocol_id === row.id)
|
||||
.map((effect) => ({
|
||||
baseValue: Number(effect.base_value),
|
||||
unit: String(effect.unit ?? ''),
|
||||
})),
|
||||
},
|
||||
],
|
||||
facts: [
|
||||
{ label: 'LEVELS', value: '01–50' },
|
||||
{
|
||||
label: 'PRIMARY EFFECT',
|
||||
value: row.primary_stat
|
||||
? `${row.primary_stat} · ${valueWithUnit(row.base_value, row.primary_unit)}`
|
||||
: 'NOT CONFIGURED',
|
||||
},
|
||||
{ label: 'TALENT UPGRADES', value: '0–5' },
|
||||
],
|
||||
lines: [
|
||||
{
|
||||
label: 'UNIQUE TALENT',
|
||||
value: list(row.talent_name, 'NOT CONFIGURED'),
|
||||
},
|
||||
],
|
||||
description: row.talent_description
|
||||
? String(row.talent_description)
|
||||
: undefined,
|
||||
};
|
||||
case 'talents':
|
||||
return talentCardItem(row, mechanicsFor(row.id));
|
||||
|
||||
@@ -34,8 +34,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
'gearMax',
|
||||
'weaponMin',
|
||||
'weaponMax',
|
||||
'chipMin',
|
||||
'chipMax',
|
||||
'capValue',
|
||||
],
|
||||
},
|
||||
@@ -47,8 +45,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
gear_max: stat.gearMax || null,
|
||||
weapon_min: stat.weaponMin || null,
|
||||
weapon_max: stat.weaponMax || null,
|
||||
skill_chip_min: stat.chipMin || null,
|
||||
skill_chip_max: stat.chipMax || null,
|
||||
cap_value: stat.capValue || null,
|
||||
}));
|
||||
const summary = await (
|
||||
@@ -211,14 +207,9 @@ export const actions: Actions = {
|
||||
let values: Array<number | null>;
|
||||
try {
|
||||
values = [
|
||||
...[
|
||||
'gearMin',
|
||||
'gearMax',
|
||||
'weaponMin',
|
||||
'weaponMax',
|
||||
'chipMin',
|
||||
'chipMax',
|
||||
].map((key) => number(key)),
|
||||
...['gearMin', 'gearMax', 'weaponMin', 'weaponMax'].map((key) =>
|
||||
number(key),
|
||||
),
|
||||
number('capValue', false),
|
||||
];
|
||||
} catch {
|
||||
@@ -226,7 +217,7 @@ export const actions: Actions = {
|
||||
error: 'Ranges must be numbers. Optional caps cannot be negative.',
|
||||
});
|
||||
}
|
||||
for (let index = 0; index < 6; index += 2)
|
||||
for (let index = 0; index < 4; index += 2)
|
||||
if (
|
||||
(values[index] === null) !== (values[index + 1] === null) ||
|
||||
(values[index] !== null &&
|
||||
@@ -253,7 +244,7 @@ export const actions: Actions = {
|
||||
if (id)
|
||||
await (
|
||||
await database.prepare(
|
||||
`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,updated_at=? WHERE id=?`,
|
||||
`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,cap_value=?,updated_at=? WHERE id=?`,
|
||||
)
|
||||
).run(
|
||||
name,
|
||||
@@ -269,7 +260,7 @@ export const actions: Actions = {
|
||||
else
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO stat_attributes(id,name,description,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
`INSERT INTO stat_attributes(id,name,description,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
)
|
||||
).run(
|
||||
statId,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
const categoryOptions = ['Weapon', 'Skill', 'Character'].map((value) => ({ value, label: value }));
|
||||
const calculationOptions = ['Additive', 'Multiplicative'].map((value) => ({ value, label: value }));
|
||||
const unitOptions = [{value:'',label:'None'},{value:'%',label:'%'},{value:'s',label:'s'},{value:'Shots',label:'Shots'},{value:'m',label:'m'}];
|
||||
const blank = () => ({id:'',name:'',description:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',skill_chip_min:'',skill_chip_max:'',cap_value:''});
|
||||
const blank = () => ({id:'',name:'',description:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',cap_value:''});
|
||||
const value = (input: unknown) => input ?? '';
|
||||
const attributeMeta = (stat: any) => stat.pending
|
||||
? `PROPOSED BY ${stat.proposerName}`
|
||||
@@ -32,7 +32,7 @@
|
||||
</script>
|
||||
{#snippet attributeEditor()}
|
||||
{#if editing}
|
||||
<div class="dashboard-editor attribute-editor"><div class="editor-title"><div><p class="eyebrow">{editing.id?'MODIFY RECORD':'NEW RECORD'}</p><h3>{editing.id?editing.name:'ATTRIBUTE DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing=null}>×</button></div><PendingForm action="?/save" class="grid"><input type="hidden" name="id" value={editing.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={editing.name} required /></label><SegmentedChoice legend="TYPE" name="type" options={typeOptions} bind:value={editing.type} required/><SegmentedChoice legend="CATEGORY" name="category" options={categoryOptions} bind:value={editing.category} required/><SegmentedChoice legend="CALCULATION" name="calculationType" options={calculationOptions} bind:value={editing.calculation_type} required/><SegmentedChoice legend="UNIT" name="unit" options={unitOptions} bind:value={editing.unit}/><label class="field attribute-description">DESCRIPTION · OPTIONAL<textarea name="description" bind:value={editing.description} maxlength="1200" placeholder="Explain what this Attribute represents and how players encounter it."></textarea></label>{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}<div class="field range-field"><span class="label">{source[1]} RANGE · OPTIONAL</span><div><input name={`${source[0]==='skill_chip'?'chip':source[0]}Min`} type="number" step="any" value={value(editing[`${source[0]}_min`])} placeholder="MIN"/><input name={`${source[0]==='skill_chip'?'chip':source[0]}Max`} type="number" step="any" value={value(editing[`${source[0]}_max`])} placeholder="MAX"/></div></div>{/each}<label class="field">OPTIONAL CAP<input name="capValue" type="number" step="any" value={value(editing.cap_value)}/></label></div>{#if form?.error}<p class="error">{form.error}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => editing=null}>CANCEL</button><button class="primary">SAVE ATTRIBUTE →</button></div></PendingForm></div>
|
||||
<div class="dashboard-editor attribute-editor"><div class="editor-title"><div><p class="eyebrow">{editing.id?'MODIFY RECORD':'NEW RECORD'}</p><h3>{editing.id?editing.name:'ATTRIBUTE DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing=null}>×</button></div><PendingForm action="?/save" class="grid"><input type="hidden" name="id" value={editing.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={editing.name} required /></label><SegmentedChoice legend="TYPE" name="type" options={typeOptions} bind:value={editing.type} required/><SegmentedChoice legend="CATEGORY" name="category" options={categoryOptions} bind:value={editing.category} required/><SegmentedChoice legend="CALCULATION" name="calculationType" options={calculationOptions} bind:value={editing.calculation_type} required/><SegmentedChoice legend="UNIT" name="unit" options={unitOptions} bind:value={editing.unit}/><label class="field attribute-description">DESCRIPTION · OPTIONAL<textarea name="description" bind:value={editing.description} maxlength="1200" placeholder="Explain what this Attribute represents and how players encounter it."></textarea></label>{#each [['gear','GEAR'],['weapon','WEAPON']] as source}<div class="field range-field"><span class="label">{source[1]} RANGE · OPTIONAL</span><div><input name={`${source[0]}Min`} type="number" step="any" value={value(editing[`${source[0]}_min`])} placeholder="MIN"/><input name={`${source[0]}Max`} type="number" step="any" value={value(editing[`${source[0]}_max`])} placeholder="MAX"/></div></div>{/each}<label class="field">OPTIONAL CAP<input name="capValue" type="number" step="any" value={value(editing.cap_value)}/></label></div>{#if form?.error}<p class="error">{form.error}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => editing=null}>CANCEL</button><button class="primary">SAVE ATTRIBUTE →</button></div></PendingForm></div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
<svelte:head><title>Attributes — SHD.build admin</title></svelte:head>
|
||||
|
||||
@@ -241,7 +241,7 @@ export const actions: Actions = {
|
||||
});
|
||||
await (
|
||||
await database.prepare(`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,
|
||||
weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,active=?,updated_at=? WHERE id=?`)
|
||||
weapon_min=?,weapon_max=?,cap_value=?,active=?,updated_at=? WHERE id=?`)
|
||||
).run(
|
||||
before.name,
|
||||
before.description ?? null,
|
||||
@@ -253,8 +253,6 @@ export const actions: Actions = {
|
||||
before.gear_max,
|
||||
before.weapon_min,
|
||||
before.weapon_max,
|
||||
before.skill_chip_min,
|
||||
before.skill_chip_max,
|
||||
before.cap_value,
|
||||
before.active,
|
||||
new Date().toISOString(),
|
||||
|
||||
@@ -12,6 +12,12 @@ import { recordIsacAudit } from '$lib/server/audit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { createNotification } from '$lib/server/notifications';
|
||||
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 modules = [
|
||||
@@ -174,16 +180,6 @@ async function skillChipProposalSnapshot(database: any, id: string) {
|
||||
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?',
|
||||
)
|
||||
).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,
|
||||
talentEffects: talent
|
||||
? await (
|
||||
@@ -317,6 +313,25 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
? ((await (
|
||||
await database.prepare('SELECT * FROM stat_attributes WHERE id=?')
|
||||
).get(String(proposal.entity_id))) ?? null)
|
||||
: proposal.module === 'skillchips' &&
|
||||
proposal.action === 'savePrimaryProfile'
|
||||
? await getSkillChipPrimaryProfiles(database)
|
||||
: proposal.module === 'skillchips' &&
|
||||
proposal.action === 'saveSecondaryPool'
|
||||
? await getSkillChipSecondaryPool(database)
|
||||
: proposal.module === 'weaponmods' &&
|
||||
proposal.action === 'saveSlotIcon' &&
|
||||
proposal.entity_id
|
||||
? {
|
||||
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,
|
||||
|
||||
@@ -21,8 +21,16 @@ import {
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
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 = {
|
||||
quality: string;
|
||||
primaryLevel1Min: number;
|
||||
@@ -30,11 +38,6 @@ type QualityValue = {
|
||||
primaryLevel20Min: number;
|
||||
primaryLevel20Max: number;
|
||||
};
|
||||
type SecondaryRange = {
|
||||
statId: string;
|
||||
values: Record<string, { min: number; max: number }>;
|
||||
};
|
||||
|
||||
function parseJson<T>(value: FormDataEntryValue | null, fallback: T): T {
|
||||
try {
|
||||
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',
|
||||
)
|
||||
).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:
|
||||
(await (
|
||||
await database.prepare(
|
||||
@@ -87,10 +80,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
asc: 'name COLLATE NOCASE ASC,id ASC',
|
||||
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: {
|
||||
asc: 'active ASC,name COLLATE NOCASE ASC',
|
||||
desc: 'active DESC,name COLLATE NOCASE ASC',
|
||||
@@ -140,7 +129,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
module: 'gearmods',
|
||||
action: 'save',
|
||||
kind: 'gearmod',
|
||||
fields: ['qualityValues', 'secondaryRanges'],
|
||||
fields: ['qualityValues'],
|
||||
},
|
||||
]),
|
||||
]);
|
||||
@@ -161,8 +150,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
pagination.page === 1 ? pendingRows.slice(0, pagination.perPage) : [];
|
||||
const databaseOffset = Math.max(0, pagination.offset - pendingRows.length);
|
||||
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
|
||||
const [mods, qualityRows, secondaryOptions, secondaryValues, stats, renders] =
|
||||
await Promise.all([
|
||||
const [mods, qualityRows, stats, renders, secondaryPool] = await Promise.all([
|
||||
databaseLimit
|
||||
? (
|
||||
await database.prepare(`SELECT * FROM gear_mods ${where}
|
||||
@@ -170,14 +158,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
).all(...args, databaseLimit, databaseOffset)
|
||||
: [],
|
||||
(await database.prepare('SELECT * FROM gear_mod_quality_values')).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM gear_mod_secondary_options')
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM gear_mod_secondary_quality_values',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||
@@ -188,11 +168,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='gear_mod_render'",
|
||||
)
|
||||
).all(),
|
||||
getModSecondaryPool(database, 'gear'),
|
||||
]);
|
||||
const parsedPending = pendingOnPage.map((item) => ({
|
||||
...item,
|
||||
qualityValues: parseJson(String(item.qualityValues), []),
|
||||
secondaryRanges: parseJson(String(item.secondaryRanges), []),
|
||||
}));
|
||||
return {
|
||||
mods: [
|
||||
@@ -208,23 +188,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
primaryLevel20Min: Number(row.primary_level_20_min),
|
||||
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: (() => {
|
||||
const render = renders.find(
|
||||
(item) => item.source_entity_id === mod.id,
|
||||
@@ -235,6 +198,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
})(),
|
||||
})),
|
||||
],
|
||||
secondaryPool,
|
||||
stats: [...pendingStats, ...stats],
|
||||
qualities,
|
||||
summary: {
|
||||
@@ -251,6 +215,60 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
};
|
||||
|
||||
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 }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
@@ -275,14 +293,16 @@ export const actions: Actions = {
|
||||
const name = String(form.get('name') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
if (form.has('secondaryRanges')) {
|
||||
return fail(400, {
|
||||
gearModError:
|
||||
'Secondary Attributes must be submitted through the shared pool editor.',
|
||||
});
|
||||
}
|
||||
const qualityValues = parseJson<QualityValue[]>(
|
||||
form.get('qualityValues'),
|
||||
[],
|
||||
);
|
||||
const secondaryRanges = parseJson<SecondaryRange[]>(
|
||||
form.get('secondaryRanges'),
|
||||
[],
|
||||
);
|
||||
if (name.length < 2 || name.length > 100)
|
||||
return fail(400, {
|
||||
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.',
|
||||
});
|
||||
}
|
||||
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();
|
||||
if (
|
||||
await (
|
||||
@@ -347,15 +343,6 @@ export const actions: Actions = {
|
||||
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 =
|
||||
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||||
const before = id ? await snapshot(id) : null;
|
||||
@@ -394,29 +381,6 @@ export const actions: Actions = {
|
||||
row.primaryLevel20Min,
|
||||
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');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
|
||||
@@ -5,26 +5,21 @@
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte';
|
||||
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
|
||||
import ModSecondaryPoolEditor from '$lib/components/ModSecondaryPoolEditor.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
|
||||
const qualityRow = (quality: string) => ({
|
||||
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 = () => ({
|
||||
id: '',
|
||||
name: '',
|
||||
active: 1,
|
||||
renderUrl: '',
|
||||
qualityValues: data.qualities.map(qualityRow),
|
||||
secondaryRanges: [blankSecondary(), blankSecondary()]
|
||||
qualityValues: data.qualities.map(qualityRow)
|
||||
});
|
||||
let editing = $state<any | null>(null);
|
||||
let levelPreview = $state(1);
|
||||
@@ -32,7 +27,6 @@
|
||||
let handledResultToken = $state('');
|
||||
let renderResetToken = $state(0);
|
||||
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 interpolate = (start: unknown, end: unknown) => Number(start) + (Number(end) - Number(start)) * (levelPreview - 1) / 19;
|
||||
const display = (value: unknown) => Number.isFinite(Number(value))
|
||||
@@ -44,22 +38,11 @@
|
||||
qualityValues: data.qualities.map((quality: string) => ({
|
||||
...qualityRow(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;
|
||||
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(() => {
|
||||
if (!openedRequestedItem && 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>
|
||||
</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={[
|
||||
{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'},
|
||||
@@ -103,7 +88,6 @@
|
||||
<PendingForm action="?/save" enctype="multipart/form-data">
|
||||
<input type="hidden" name="id" value={editing.id}/>
|
||||
<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">
|
||||
<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">
|
||||
@@ -143,34 +127,12 @@
|
||||
{#if previewRow}
|
||||
<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>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>
|
||||
{/if}
|
||||
</div>
|
||||
</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}
|
||||
<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>
|
||||
@@ -186,12 +148,12 @@
|
||||
|
||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||
<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}
|
||||
<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>
|
||||
<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>01 → 20</span>
|
||||
<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 (
|
||||
await database.prepare(`
|
||||
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
|
||||
WHERE active=1
|
||||
ORDER BY category COLLATE NOCASE,type COLLATE NOCASE,name COLLATE NOCASE
|
||||
|
||||
@@ -19,8 +19,6 @@
|
||||
gear_max?: number | null;
|
||||
weapon_min?: number | null;
|
||||
weapon_max?: number | null;
|
||||
skill_chip_min?: number | null;
|
||||
skill_chip_max?: number | null;
|
||||
};
|
||||
|
||||
let { data } = $props();
|
||||
@@ -131,7 +129,6 @@
|
||||
const sourceRanges = (attribute: AttributeVariable) => [
|
||||
{ label: 'GEAR', value: valueRange(attribute.gear_min, attribute.gear_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);
|
||||
|
||||
const superiorFirepower = [
|
||||
|
||||
@@ -35,27 +35,15 @@ import {
|
||||
saveConditionalRuleInTransaction,
|
||||
} from '$lib/server/conditional-effects';
|
||||
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 attributeTypes = ['Firepower', 'Toughness', 'Engineering'];
|
||||
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) =>
|
||||
render
|
||||
@@ -88,15 +76,15 @@ const descriptionOutcomes = (
|
||||
Number(left.descriptionSlot) - Number(right.descriptionSlot),
|
||||
);
|
||||
|
||||
async function protocolSnapshot(id: string) {
|
||||
const database = await getDatabase();
|
||||
async function protocolSnapshot(id: string, suppliedDatabase?: Database) {
|
||||
const database = suppliedDatabase ?? (await getDatabase());
|
||||
const talent = await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM os_protocol_talents WHERE protocol_id=?',
|
||||
)
|
||||
).get(id);
|
||||
return {
|
||||
record: await auditRecord('os_protocols', id),
|
||||
record: await auditRecord('os_protocols', id, database),
|
||||
icon: await gameDataIconMetadata(database, 'os_protocol', id),
|
||||
render: renderAuditMetadata(
|
||||
await (
|
||||
@@ -129,8 +117,8 @@ async function protocolSnapshot(id: string) {
|
||||
};
|
||||
}
|
||||
|
||||
async function profileSnapshot(id: string) {
|
||||
const database = await getDatabase();
|
||||
async function profileSnapshot(id: string, suppliedDatabase?: Database) {
|
||||
const database = suppliedDatabase ?? (await getDatabase());
|
||||
return {
|
||||
record:
|
||||
(await (
|
||||
@@ -292,6 +280,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
'attributeType',
|
||||
'primaryStatId',
|
||||
'primaryBaseValue',
|
||||
'hasTalent',
|
||||
'talentName',
|
||||
'descriptionTemplate',
|
||||
'conditionalRule',
|
||||
@@ -341,7 +330,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
profile.quality === protocol.quality &&
|
||||
profile.primary_attribute_type === protocol.attributeType,
|
||||
) ?? null,
|
||||
talent: {
|
||||
talent:
|
||||
protocol.hasTalent === '1'
|
||||
? {
|
||||
name: protocol.talentName,
|
||||
description_template: protocol.descriptionTemplate,
|
||||
conditionalRule: (() => {
|
||||
@@ -351,7 +342,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
},
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
const configuredProtocols = await Promise.all(
|
||||
protocols.map(async (protocol) => {
|
||||
@@ -450,27 +442,26 @@ export const actions: Actions = {
|
||||
profileError: 'Choose a valid quality and attribute type.',
|
||||
});
|
||||
const database = await getDatabase();
|
||||
const expected = expectedSecondaryAttributes[attributeType];
|
||||
const statRows = await (
|
||||
await database.prepare(
|
||||
`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),
|
||||
]),
|
||||
const attributeIds = await getOsProtocolProfileAttributeIds(
|
||||
database,
|
||||
attributeType as OsProtocolAttributeType,
|
||||
);
|
||||
if (statRows.length !== 3)
|
||||
if (Object.values(attributeIds).some((id) => !id))
|
||||
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) => {
|
||||
const rawValue = form.get(`value${role}`);
|
||||
return {
|
||||
role,
|
||||
statId: statByName.get(expected[role].toLowerCase()) ?? '',
|
||||
value: Number(form.get(`value${role}`)),
|
||||
}));
|
||||
statId: attributeIds[role as keyof typeof attributeIds] ?? '',
|
||||
value:
|
||||
typeof rawValue === 'string' && rawValue.trim()
|
||||
? Number(rawValue)
|
||||
: Number.NaN,
|
||||
};
|
||||
});
|
||||
if (
|
||||
effects.some(
|
||||
(effect) =>
|
||||
@@ -481,37 +472,45 @@ export const actions: Actions = {
|
||||
profileError:
|
||||
'Every shared secondary attribute requires a non-negative value.',
|
||||
});
|
||||
const saveProfile = database.transactionAsync(async (transaction) => {
|
||||
const tx = transaction as unknown as Database;
|
||||
const existing = await (
|
||||
await database.prepare(
|
||||
await tx.prepare(
|
||||
'SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?',
|
||||
)
|
||||
).get(quality, attributeType);
|
||||
const profileId = String(existing?.id ?? randomUUID()),
|
||||
now = new Date().toISOString();
|
||||
const before = existing ? await profileSnapshot(profileId) : null;
|
||||
const profileId = String(existing?.id ?? randomUUID());
|
||||
const now = new Date().toISOString();
|
||||
const before = existing ? await profileSnapshot(profileId, tx) : null;
|
||||
if (existing)
|
||||
await (
|
||||
await database.prepare(
|
||||
await tx.prepare(
|
||||
'UPDATE os_protocol_secondary_profiles SET updated_at=? WHERE id=?',
|
||||
)
|
||||
).run(now, profileId);
|
||||
else
|
||||
await (
|
||||
await database.prepare(
|
||||
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 database.prepare(
|
||||
await tx.prepare(
|
||||
'DELETE FROM os_protocol_secondary_effects WHERE profile_id=?',
|
||||
)
|
||||
).run(profileId);
|
||||
for (const effect of effects)
|
||||
await (
|
||||
await database.prepare(
|
||||
await tx.prepare(
|
||||
'INSERT INTO os_protocol_secondary_effects(id,profile_id,effect_role,stat_attribute_id,value) VALUES(?,?,?,?,?)',
|
||||
)
|
||||
).run(randomUUID(), profileId, effect.role, effect.statId, effect.value);
|
||||
).run(
|
||||
randomUUID(),
|
||||
profileId,
|
||||
effect.role,
|
||||
effect.statId,
|
||||
effect.value,
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
@@ -520,8 +519,11 @@ export const actions: Actions = {
|
||||
: 'os_protocol_profile.created',
|
||||
entityId: profileId,
|
||||
before,
|
||||
after: await profileSnapshot(profileId),
|
||||
after: await profileSnapshot(profileId, tx),
|
||||
database: tx,
|
||||
});
|
||||
});
|
||||
await saveProfile.immediate();
|
||||
return { profileSaved: true, resultToken: randomUUID() };
|
||||
},
|
||||
save: async ({ request, cookies }) => {
|
||||
@@ -559,7 +561,12 @@ export const actions: Actions = {
|
||||
const quality = String(form.get('quality') ?? '');
|
||||
const attributeType = String(form.get('attributeType') ?? '');
|
||||
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') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
@@ -567,11 +574,15 @@ export const actions: Actions = {
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
const database = await getDatabase();
|
||||
let conditionalRule;
|
||||
let conditionalRule: ReturnType<typeof parseConditionalRulePayload> | null =
|
||||
null;
|
||||
try {
|
||||
if (hasTalent) {
|
||||
const conditionalRuleRaw = form.get('conditionalRule');
|
||||
const activeStats = await (
|
||||
await database.prepare('SELECT id FROM stat_attributes WHERE active=1')
|
||||
await database.prepare(
|
||||
'SELECT id FROM stat_attributes WHERE active=1',
|
||||
)
|
||||
).all();
|
||||
conditionalRule = parseConditionalRulePayload(
|
||||
conditionalRuleRaw,
|
||||
@@ -583,6 +594,7 @@ export const actions: Actions = {
|
||||
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
return fail(400, {
|
||||
protocolError:
|
||||
@@ -608,29 +620,39 @@ export const actions: Actions = {
|
||||
protocolError:
|
||||
'Choose the protocol’s unique primary stat and a non-negative level-1 base value.',
|
||||
});
|
||||
const outcomes = mechanicOutcomes(conditionalRule);
|
||||
const numberedOutcomes = descriptionOutcomes(conditionalRule);
|
||||
const outcomes = conditionalRule ? mechanicOutcomes(conditionalRule) : [];
|
||||
const numberedOutcomes = conditionalRule
|
||||
? descriptionOutcomes(conditionalRule)
|
||||
: [];
|
||||
const durationSeconds =
|
||||
conditionalRule.pools.find((pool) => pool.expiresAfterSeconds)
|
||||
conditionalRule?.pools.find((pool) => pool.expiresAfterSeconds)
|
||||
?.expiresAfterSeconds ?? null;
|
||||
const maxStacks = conditionalRule.pools.length
|
||||
const maxStacks = conditionalRule?.pools.length
|
||||
? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1))
|
||||
: null;
|
||||
const requiresCompleteMechanic =
|
||||
hasTalent && conditionalRule?.simulationReadiness !== 'captured_only';
|
||||
if (
|
||||
talentName.length < 2 ||
|
||||
talentName.length > 80 ||
|
||||
!descriptionTemplate ||
|
||||
!conditionalRule.clauses.length ||
|
||||
!outcomes.length
|
||||
hasTalent &&
|
||||
(talentName.length < 2 || talentName.length > 80 || !descriptionTemplate)
|
||||
)
|
||||
return fail(400, {
|
||||
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(
|
||||
(match) => Number(match[1]),
|
||||
);
|
||||
if (
|
||||
requiresCompleteMechanic &&
|
||||
placeholders.some(
|
||||
(index) =>
|
||||
!numberedOutcomes.some(
|
||||
@@ -642,23 +664,49 @@ export const actions: Actions = {
|
||||
protocolError:
|
||||
'Description placeholders must reference configured numbered outcomes.',
|
||||
});
|
||||
if (descriptionTemplate.includes('{duration}') && durationSeconds === null)
|
||||
if (
|
||||
requiresCompleteMechanic &&
|
||||
descriptionTemplate.includes('{duration}') &&
|
||||
durationSeconds === null
|
||||
)
|
||||
return fail(400, {
|
||||
protocolError:
|
||||
'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, {
|
||||
protocolError:
|
||||
'The description uses {max_stacks}, but no state pool maximum is defined.',
|
||||
});
|
||||
if (
|
||||
!(await (
|
||||
const profileAttributeIds = await getOsProtocolProfileAttributeIds(
|
||||
database,
|
||||
attributeType as OsProtocolAttributeType,
|
||||
);
|
||||
const completeProfile =
|
||||
Object.values(profileAttributeIds).every(Boolean) &&
|
||||
(await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?',
|
||||
)
|
||||
).get(quality, attributeType))
|
||||
`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,
|
||||
profileAttributeIds.Core,
|
||||
profileAttributeIds.Damage,
|
||||
profileAttributeIds.Health,
|
||||
));
|
||||
if (!completeProfile)
|
||||
return fail(400, {
|
||||
protocolError:
|
||||
'Configure the shared secondary profile for this quality and attribute type first.',
|
||||
@@ -679,11 +727,21 @@ export const actions: Actions = {
|
||||
).get(id)
|
||||
: null;
|
||||
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(
|
||||
'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, {
|
||||
protocolError: 'That protocol talent name is already in use.',
|
||||
@@ -701,36 +759,38 @@ export const actions: Actions = {
|
||||
return fail(400, {
|
||||
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()),
|
||||
now = new Date().toISOString();
|
||||
const before = id ? await protocolSnapshot(id) : null;
|
||||
const saveProtocol = database.transactionAsync(async (transaction) => {
|
||||
const tx = transaction as unknown as Database;
|
||||
const before = id ? await protocolSnapshot(id, tx) : null;
|
||||
if (id && !before?.record)
|
||||
return fail(404, { protocolError: 'OS Protocol not found.' });
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
if (id) {
|
||||
await (
|
||||
await database.prepare(
|
||||
await tx.prepare(
|
||||
'UPDATE os_protocols SET name=?,quality=?,primary_attribute_type=?,active=1,updated_at=? WHERE id=?',
|
||||
)
|
||||
).run(name, quality, attributeType, now, id);
|
||||
} else
|
||||
await (
|
||||
await database.prepare(
|
||||
await tx.prepare(
|
||||
'INSERT INTO os_protocols(id,name,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?,?)',
|
||||
)
|
||||
).run(protocolId, name, quality, attributeType, now, now);
|
||||
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`)
|
||||
).run(randomUUID(), protocolId, primaryStatId, primaryBaseValue);
|
||||
if (hasTalent && conditionalRule) {
|
||||
await (
|
||||
await database.prepare(`INSERT INTO os_protocol_talents(id,protocol_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
|
||||
await tx.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(
|
||||
await tx.prepare(
|
||||
'DELETE FROM os_protocol_talent_effects WHERE talent_id=?',
|
||||
)
|
||||
).run(talentId);
|
||||
@@ -740,7 +800,7 @@ export const actions: Actions = {
|
||||
continue;
|
||||
compatibilitySortOrder += 1;
|
||||
await (
|
||||
await database.prepare(
|
||||
await tx.prepare(
|
||||
'INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) VALUES(?,?,?,?,?)',
|
||||
)
|
||||
).run(
|
||||
@@ -752,20 +812,30 @@ export const actions: Actions = {
|
||||
);
|
||||
}
|
||||
await saveConditionalRuleInTransaction(
|
||||
database,
|
||||
tx,
|
||||
'os_protocol_talent',
|
||||
talentId,
|
||||
conditionalRule,
|
||||
);
|
||||
} 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 applyIconMutation(
|
||||
database,
|
||||
tx,
|
||||
'os_protocol',
|
||||
protocolId,
|
||||
iconMutation,
|
||||
operator.id,
|
||||
);
|
||||
await applyGameDataRender(
|
||||
database,
|
||||
tx,
|
||||
'os_protocol',
|
||||
protocolId,
|
||||
name,
|
||||
@@ -778,13 +848,13 @@ export const actions: Actions = {
|
||||
action: id ? 'os_protocol.updated' : 'os_protocol.created',
|
||||
entityId: protocolId,
|
||||
before,
|
||||
after: await protocolSnapshot(protocolId),
|
||||
after: await protocolSnapshot(protocolId, tx),
|
||||
database: tx,
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const transactionResult = await saveProtocol.immediate();
|
||||
if (transactionResult) return transactionResult;
|
||||
return {
|
||||
protocolSaved: true,
|
||||
protocolMode: id ? 'edit' : 'add',
|
||||
|
||||
@@ -29,15 +29,16 @@
|
||||
simulationReadiness: 'captured_only',
|
||||
scenarioInputs: [],
|
||||
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 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 profileEditing = $state<any | null>(null);
|
||||
let talentUpgrade = $state(0);
|
||||
let protocolLevel = $state(1);
|
||||
let handledResultToken = $state('');
|
||||
let handledErrorForm = $state<unknown>(null);
|
||||
let iconResetToken = $state(0);
|
||||
let renderResetToken = $state(0);
|
||||
let openedRequestedItem = $state(false);
|
||||
@@ -86,6 +87,8 @@
|
||||
editing = {
|
||||
...protocol,
|
||||
primaryEffect: protocol.primaryEffect ? { ...protocol.primaryEffect } : { stat_attribute_id: '', base_value: '' },
|
||||
hasTalent: Boolean(protocol.talent),
|
||||
talentOpen: false,
|
||||
talent: protocol.talent ? {
|
||||
...protocol.talent,
|
||||
conditionalRule: protocol.talent.conditionalRule ?? blankRule()
|
||||
@@ -115,6 +118,10 @@
|
||||
talentUpgrade = 0;
|
||||
protocolLevel = 1;
|
||||
}
|
||||
if (form?.protocolError && form !== handledErrorForm) {
|
||||
handledErrorForm = form;
|
||||
if (editing?.hasTalent) editing.talentOpen = true;
|
||||
}
|
||||
});
|
||||
</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>
|
||||
<PendingForm action="?/save" enctype="multipart/form-data">
|
||||
<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)} />
|
||||
<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>
|
||||
@@ -178,7 +186,11 @@
|
||||
<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">
|
||||
<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>
|
||||
{#if editing.hasTalent}
|
||||
<details class="modeling-disclosure protocol-talent-disclosure" bind:open={editing.talentOpen}>
|
||||
<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>
|
||||
<div class="modeling-content">
|
||||
<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>
|
||||
<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>
|
||||
<MechanicProgramEditor
|
||||
@@ -193,6 +205,12 @@
|
||||
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>
|
||||
{#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>
|
||||
@@ -206,7 +224,7 @@
|
||||
<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>
|
||||
{#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}
|
||||
{: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>
|
||||
|
||||
@@ -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,
|
||||
getRegistrySort,
|
||||
} 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 { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
@@ -36,6 +39,18 @@ import {
|
||||
saveConditionalRuleInTransaction,
|
||||
} from '$lib/server/conditional-effects';
|
||||
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 = (
|
||||
rule: ReturnType<typeof parseConditionalRulePayload>,
|
||||
@@ -68,6 +83,16 @@ const renderAuditMetadata = (render: Record<string, any> | null | undefined) =>
|
||||
}
|
||||
: 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) {
|
||||
const database = await getDatabase();
|
||||
const talent = await (
|
||||
@@ -91,16 +116,6 @@ async function snapshot(id: string) {
|
||||
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?',
|
||||
)
|
||||
).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,
|
||||
talentEffects: talent
|
||||
? 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",
|
||||
},
|
||||
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',
|
||||
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',
|
||||
asc: 'sets.name COLLATE NOCASE ASC,sets.id ASC',
|
||||
desc: 'sets.name COLLATE NOCASE DESC,sets.id DESC',
|
||||
},
|
||||
state: {
|
||||
asc: 'sets.active ASC,sets.name COLLATE NOCASE ASC',
|
||||
@@ -173,7 +188,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
: 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_talents talent WHERE talent.skill_chip_set_id=sets.id)',
|
||||
]
|
||||
: []),
|
||||
...(!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=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 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`)
|
||||
).get(),
|
||||
(
|
||||
@@ -211,7 +225,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
module: 'attributes',
|
||||
action: 'save',
|
||||
kind: 'stat',
|
||||
fields: ['unit', 'chipMin', 'chipMax'],
|
||||
fields: ['unit'],
|
||||
},
|
||||
]),
|
||||
getPendingEntities(user.id, [
|
||||
@@ -224,8 +238,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
'twoPieceStatId',
|
||||
'twoPieceMin',
|
||||
'twoPieceMax',
|
||||
'primaryStatIds',
|
||||
'secondaryStatIds',
|
||||
'hasTalent',
|
||||
'talentName',
|
||||
'descriptionTemplate',
|
||||
'conditionalRule',
|
||||
@@ -252,8 +265,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const [
|
||||
sets,
|
||||
twoPiece,
|
||||
primaryOptions,
|
||||
secondaryOptions,
|
||||
talents,
|
||||
talentEffects,
|
||||
stats,
|
||||
@@ -262,6 +273,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
abilityChipCounts,
|
||||
icons,
|
||||
renders,
|
||||
primaryProfiles,
|
||||
secondaryPool,
|
||||
primaryAttributeIds,
|
||||
] = await Promise.all([
|
||||
(
|
||||
await database.prepare(
|
||||
@@ -271,10 +285,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
(
|
||||
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM skill_chip_secondary_options')
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
@@ -283,7 +293,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
).all(),
|
||||
(
|
||||
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(),
|
||||
(
|
||||
@@ -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'",
|
||||
)
|
||||
).all(),
|
||||
getSkillChipPrimaryProfiles(database),
|
||||
getSkillChipSecondaryPool(database),
|
||||
getSkillChipPrimaryAttributeIds(database),
|
||||
]);
|
||||
const availableStats = [
|
||||
...pendingStats.map((item) => ({
|
||||
...item,
|
||||
unit: String(item.unit ?? ''),
|
||||
skill_chip_min: item.chipMin === '' ? null : Number(item.chipMin),
|
||||
skill_chip_max: item.chipMax === '' ? null : Number(item.chipMax),
|
||||
})),
|
||||
...stats,
|
||||
];
|
||||
@@ -347,9 +358,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const currentPendingSets = pendingSets.filter(
|
||||
(set) =>
|
||||
Boolean(set.abilityId) &&
|
||||
Array.isArray(set.primaryStatIds) &&
|
||||
Array.isArray(set.secondaryStatIds) &&
|
||||
Boolean(set.conditionalRule),
|
||||
(String(set.hasTalent) !== '1' || Boolean(set.conditionalRule)),
|
||||
);
|
||||
const pendingSetRows = requestedItemId
|
||||
? currentPendingSets.filter((set) => set.id === requestedItemId)
|
||||
@@ -374,19 +383,22 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
min_value: Number(set.twoPieceMin),
|
||||
max_value: Number(set.twoPieceMax),
|
||||
},
|
||||
primaryStatIds: set.primaryStatIds,
|
||||
secondaryStatIds: set.secondaryStatIds,
|
||||
talent: {
|
||||
talent:
|
||||
String(set.hasTalent) === '1'
|
||||
? {
|
||||
name: set.talentName,
|
||||
description_template: set.descriptionTemplate,
|
||||
conditionalRule: (() => {
|
||||
try {
|
||||
return JSON.parse(String(set.conditionalRule ?? 'null'));
|
||||
return JSON.parse(
|
||||
String(set.conditionalRule ?? 'null'),
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
},
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
const configuredSets = await Promise.all(
|
||||
sets.map(async (set) => {
|
||||
@@ -407,12 +419,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
})(),
|
||||
twoPiece:
|
||||
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,
|
||||
@@ -433,9 +439,15 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
return {
|
||||
sets: [...pendingSetRows, ...configuredSets],
|
||||
stats: availableStats,
|
||||
rollStats: availableStats.filter(
|
||||
(stat) => stat.skill_chip_min !== null && stat.skill_chip_max !== null,
|
||||
primaryProfiles: primaryAttributeIds.map(
|
||||
(statId) =>
|
||||
primaryProfiles.find((profile) => profile.statId === statId) ?? {
|
||||
statId,
|
||||
values: {},
|
||||
},
|
||||
),
|
||||
primaryProfileBase: primaryProfiles,
|
||||
secondaryPool,
|
||||
abilities: loadedAbilities,
|
||||
summary: {
|
||||
active: Number(summary?.active ?? 0),
|
||||
@@ -490,12 +502,7 @@ export const actions: Actions = {
|
||||
const twoPieceStatId = String(form.get('twoPieceStatId') ?? '');
|
||||
const twoPieceMin = Number(form.get('twoPieceMin'));
|
||||
const twoPieceMax = Number(form.get('twoPieceMax'));
|
||||
const primaryStatIds = [
|
||||
...new Set(form.getAll('primaryStatIds').map(String).filter(Boolean)),
|
||||
];
|
||||
const secondaryStatIds = [
|
||||
...new Set(form.getAll('secondaryStatIds').map(String).filter(Boolean)),
|
||||
];
|
||||
const hasTalent = form.get('hasTalent') === '1';
|
||||
const talentName = String(form.get('talentName') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
@@ -503,11 +510,15 @@ export const actions: Actions = {
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
const database = await getDatabase();
|
||||
let conditionalRule;
|
||||
let conditionalRule: ReturnType<typeof parseConditionalRulePayload> | null =
|
||||
null;
|
||||
try {
|
||||
if (hasTalent) {
|
||||
const conditionalRuleRaw = form.get('conditionalRule');
|
||||
const activeStats = await (
|
||||
await database.prepare('SELECT id FROM stat_attributes WHERE active=1')
|
||||
await database.prepare(
|
||||
'SELECT id FROM stat_attributes WHERE active=1',
|
||||
)
|
||||
).all();
|
||||
conditionalRule = parseConditionalRulePayload(
|
||||
conditionalRuleRaw,
|
||||
@@ -522,6 +533,7 @@ export const actions: Actions = {
|
||||
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
return fail(400, {
|
||||
skillChipError:
|
||||
@@ -547,38 +559,30 @@ export const actions: Actions = {
|
||||
return fail(400, {
|
||||
skillChipError: 'Configure a valid 2-piece stat range.',
|
||||
});
|
||||
if (!primaryStatIds.length)
|
||||
return fail(400, {
|
||||
skillChipError: 'Configure at least one possible primary stat.',
|
||||
});
|
||||
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 outcomes = conditionalRule ? mechanicOutcomes(conditionalRule) : [];
|
||||
const numberedOutcomes = conditionalRule
|
||||
? descriptionOutcomes(conditionalRule)
|
||||
: [];
|
||||
const durationSeconds =
|
||||
conditionalRule.pools.find((pool) => pool.expiresAfterSeconds)
|
||||
conditionalRule?.pools.find((pool) => pool.expiresAfterSeconds)
|
||||
?.expiresAfterSeconds ?? null;
|
||||
const maxStacks = conditionalRule.pools.length
|
||||
const maxStacks = conditionalRule?.pools.length
|
||||
? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1))
|
||||
: null;
|
||||
if (
|
||||
talentName.length < 2 ||
|
||||
hasTalent &&
|
||||
(talentName.length < 2 ||
|
||||
talentName.length > 80 ||
|
||||
!descriptionTemplate ||
|
||||
!conditionalRule.clauses.length ||
|
||||
!outcomes.length
|
||||
!conditionalRule?.clauses.length ||
|
||||
!outcomes.length)
|
||||
)
|
||||
return fail(400, {
|
||||
skillChipError:
|
||||
'The 3-piece talent requires a name, description, mechanic branch, and outcome.',
|
||||
});
|
||||
if (
|
||||
hasTalent &&
|
||||
outcomes.some(
|
||||
(outcome) =>
|
||||
outcome.variants.maximum.value < outcome.variants.minimum.value,
|
||||
@@ -592,6 +596,7 @@ export const actions: Actions = {
|
||||
(match) => Number(match[1]),
|
||||
);
|
||||
if (
|
||||
hasTalent &&
|
||||
placeholders.some(
|
||||
(index) =>
|
||||
!numberedOutcomes.some(
|
||||
@@ -603,12 +608,16 @@ export const actions: Actions = {
|
||||
skillChipError:
|
||||
'Description placeholders must reference configured numbered outcomes.',
|
||||
});
|
||||
if (descriptionTemplate.includes('{duration}') && durationSeconds === null)
|
||||
if (
|
||||
hasTalent &&
|
||||
descriptionTemplate.includes('{duration}') &&
|
||||
durationSeconds === null
|
||||
)
|
||||
return fail(400, {
|
||||
skillChipError:
|
||||
'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, {
|
||||
skillChipError:
|
||||
'The description uses {max_stacks}, but no state pool maximum is defined.',
|
||||
@@ -656,11 +665,21 @@ export const actions: Actions = {
|
||||
).get(id)
|
||||
: null;
|
||||
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(
|
||||
'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, {
|
||||
skillChipError: 'That Skill Chip talent name is already in use.',
|
||||
@@ -668,14 +687,7 @@ export const actions: Actions = {
|
||||
const outcomeStatIds = outcomes
|
||||
.map((effect) => effect.attributeId)
|
||||
.filter((value): value is string => Boolean(value));
|
||||
const allStatIds = [
|
||||
...new Set([
|
||||
twoPieceStatId,
|
||||
...primaryStatIds,
|
||||
...secondaryStatIds,
|
||||
...outcomeStatIds,
|
||||
]),
|
||||
];
|
||||
const allStatIds = [...new Set([twoPieceStatId, ...outcomeStatIds])];
|
||||
const validStats = await (
|
||||
await database.prepare(
|
||||
`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, {
|
||||
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 =
|
||||
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||||
const talentId = String(existingTalent?.id ?? randomUUID());
|
||||
@@ -724,28 +725,7 @@ 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(?,?,?,?,?)
|
||||
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);
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM skill_chip_primary_options WHERE skill_chip_set_id=?',
|
||||
)
|
||||
).run(setId);
|
||||
for (const statId of primaryStatIds)
|
||||
await (
|
||||
await database.prepare(
|
||||
'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);
|
||||
if (hasTalent && conditionalRule) {
|
||||
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`)
|
||||
@@ -779,6 +759,16 @@ export const actions: Actions = {
|
||||
talentId,
|
||||
conditionalRule,
|
||||
);
|
||||
} 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 applyIconMutation(
|
||||
database,
|
||||
'skill_chip',
|
||||
@@ -804,6 +794,7 @@ export const actions: Actions = {
|
||||
entityId: setId,
|
||||
before,
|
||||
after: await snapshot(setId),
|
||||
database,
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
@@ -816,16 +807,140 @@ export const actions: Actions = {
|
||||
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 }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
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();
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const before = await snapshot(id);
|
||||
if (!before.record) {
|
||||
await database.exec('ROLLBACK');
|
||||
return fail(404, { skillChipError: 'Skill Chip set not found.' });
|
||||
}
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE skill_chip_sets SET active=0,updated_at=? WHERE id=?',
|
||||
@@ -842,7 +957,13 @@ export const actions: Actions = {
|
||||
entityId: id,
|
||||
before,
|
||||
after: await snapshot(id),
|
||||
database,
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
return { skillChipArchived: true };
|
||||
},
|
||||
unarchive: async ({ request, cookies }) => {
|
||||
@@ -851,11 +972,15 @@ export const actions: Actions = {
|
||||
'data.manage',
|
||||
);
|
||||
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();
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const before = await snapshot(id);
|
||||
if (!before.record) {
|
||||
await database.exec('ROLLBACK');
|
||||
return fail(404, { skillChipError: 'Skill Chip set not found.' });
|
||||
}
|
||||
const abilityId = String(before.record.specialization_ability_id);
|
||||
if (
|
||||
!(await (
|
||||
await database.prepare(
|
||||
@@ -863,6 +988,7 @@ export const actions: Actions = {
|
||||
)
|
||||
).get(abilityId))
|
||||
) {
|
||||
await database.exec('ROLLBACK');
|
||||
return fail(400, {
|
||||
skillChipError:
|
||||
'The assigned specialization ability must be active before restoring this Skill Chip.',
|
||||
@@ -876,11 +1002,13 @@ export const actions: Actions = {
|
||||
).get(abilityId, id)
|
||||
)?.count ?? 0,
|
||||
);
|
||||
if (assignedCount >= 3)
|
||||
if (assignedCount >= 3) {
|
||||
await database.exec('ROLLBACK');
|
||||
return fail(409, {
|
||||
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=?',
|
||||
@@ -894,7 +1022,13 @@ export const actions: Actions = {
|
||||
entityId: id,
|
||||
before,
|
||||
after: await snapshot(id),
|
||||
database,
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
return { skillChipUnarchived: true };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
|
||||
import SkillChipSharedProfiles from '$lib/components/SkillChipSharedProfiles.svelte';
|
||||
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
||||
|
||||
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}}}]}]
|
||||
});
|
||||
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 highEndCount = $state('0');
|
||||
let handledResultToken = $state('');
|
||||
@@ -65,9 +66,9 @@
|
||||
...item,
|
||||
abilityId: String(item.specialization_ability_id),
|
||||
twoPiece: item.twoPiece ? { ...item.twoPiece } : { stat_attribute_id: '', min_value: '', max_value: '' },
|
||||
primaryStatIds: [...item.primaryStatIds],
|
||||
secondaryStatIds: [...item.secondaryStatIds],
|
||||
talent: item.talent ? { ...item.talent, conditionalRule: item.talent.conditionalRule ?? blankRule() } : blankTalent()
|
||||
hasTalent: Boolean(item.talent),
|
||||
talentOpen: false,
|
||||
talent: item.talent ? structuredClone({ ...item.talent, conditionalRule: item.talent.conditionalRule ?? blankRule() }) : blankTalent()
|
||||
};
|
||||
highEndCount = '0';
|
||||
};
|
||||
@@ -86,17 +87,30 @@
|
||||
editing = form.skillChipMode === 'add' ? blank() : null;
|
||||
highEndCount = '0';
|
||||
}
|
||||
if (form?.skillChipError && editing?.hasTalent) editing.talentOpen = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<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={[
|
||||
{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:'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'}
|
||||
]}/>
|
||||
|
||||
@@ -106,8 +120,9 @@
|
||||
<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">
|
||||
<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)}/>
|
||||
<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">
|
||||
<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>
|
||||
<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}/>
|
||||
@@ -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>
|
||||
</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">
|
||||
<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-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
|
||||
attributes={data.stats}
|
||||
allowAssignedAbility
|
||||
@@ -139,9 +153,12 @@
|
||||
}}
|
||||
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>
|
||||
{#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>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -150,9 +167,9 @@
|
||||
|
||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||
<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}
|
||||
<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>EXCLUSIVE 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}
|
||||
{: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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const events: string[] = [];
|
||||
return {
|
||||
events,
|
||||
talentExists: true,
|
||||
auditRecord: vi.fn(),
|
||||
conditionalRuleSnapshot: vi.fn(),
|
||||
gameDataIconMetadata: vi.fn(async () => null),
|
||||
recordIsacAudit: vi.fn(async () => {
|
||||
events.push('audit');
|
||||
}),
|
||||
database: {
|
||||
exec: vi.fn(async (sql: string) => {
|
||||
events.push(sql);
|
||||
}),
|
||||
prepare: 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: mocks.auditRecord,
|
||||
recordIsacAudit: mocks.recordIsacAudit,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/data-moderation', () => ({
|
||||
getPendingEntities: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/game-data-icons', () => ({
|
||||
applyIconMutation: vi.fn(),
|
||||
gameDataIconMetadata: mocks.gameDataIconMetadata,
|
||||
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/specialization-readiness', () => ({
|
||||
refreshAbilityFocuses: vi.fn(),
|
||||
refreshSpecializationFocusReady: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/conditional-effects', () => ({
|
||||
conditionalRuleSnapshot: mocks.conditionalRuleSnapshot,
|
||||
loadConditionalRuleDraft: vi.fn(),
|
||||
parseConditionalRulePayload: vi.fn(),
|
||||
saveConditionalRuleInTransaction: vi.fn(),
|
||||
}));
|
||||
|
||||
import { actions } from './+page.server';
|
||||
|
||||
const beforeRecord = {
|
||||
id: 'set-1',
|
||||
name: 'Pulse Matrix',
|
||||
specialization_ability_id: 'ability-1',
|
||||
active: 1,
|
||||
};
|
||||
const afterRecord = { ...beforeRecord, updated_at: '2026-08-02T12:00:00.000Z' };
|
||||
const existingTalent = {
|
||||
id: 'talent-1',
|
||||
skill_chip_set_id: 'set-1',
|
||||
name: 'Signal Cascade',
|
||||
description_template: 'Existing Talent',
|
||||
};
|
||||
const twoPiece = {
|
||||
skill_chip_set_id: 'set-1',
|
||||
stat_attribute_id: 'skill-duration',
|
||||
min_value: 5,
|
||||
max_value: 10,
|
||||
};
|
||||
const talentEffects = [
|
||||
{
|
||||
id: 'effect-1',
|
||||
talent_id: 'talent-1',
|
||||
stat_attribute_id: 'skill-intensity',
|
||||
min_value: 1,
|
||||
max_value: 2,
|
||||
sort_order: 1,
|
||||
},
|
||||
];
|
||||
const conditional = { id: 'rule-1', source_id: 'talent-1' };
|
||||
|
||||
const saveRequest = (confirmRemoval = false) => {
|
||||
const form = new FormData();
|
||||
form.set('id', 'set-1');
|
||||
form.set('name', 'Pulse Matrix');
|
||||
form.set('abilityId', 'ability-1');
|
||||
form.set('twoPieceStatId', 'skill-duration');
|
||||
form.set('twoPieceMin', '5');
|
||||
form.set('twoPieceMax', '10');
|
||||
form.set('hasTalent', '0');
|
||||
if (confirmRemoval) form.set('confirmTalentRemoval', 'REMOVE');
|
||||
return new Request('http://localhost/isac/skillchips?/save', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
};
|
||||
|
||||
const runSave = (confirmRemoval = false) =>
|
||||
actions.save?.({
|
||||
cookies: {} as never,
|
||||
request: saveRequest(confirmRemoval),
|
||||
} as never);
|
||||
|
||||
describe('Skill Chip Talent removal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.events.length = 0;
|
||||
mocks.talentExists = true;
|
||||
mocks.auditRecord
|
||||
.mockResolvedValueOnce(beforeRecord)
|
||||
.mockResolvedValueOnce(afterRecord);
|
||||
mocks.conditionalRuleSnapshot.mockResolvedValue(conditional);
|
||||
mocks.database.prepare.mockImplementation(async (sql: string) => ({
|
||||
get: vi.fn(async (...args: unknown[]) => {
|
||||
if (sql.includes('FROM specialization_abilities'))
|
||||
return { id: 'ability-1' };
|
||||
if (sql.includes('COUNT(*) AS count FROM skill_chip_sets'))
|
||||
return { count: 2 };
|
||||
if (sql.includes('FROM skill_chip_sets WHERE name=')) return null;
|
||||
if (sql.includes('FROM skill_chip_talents WHERE skill_chip_set_id='))
|
||||
return mocks.talentExists ? existingTalent : 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 skill_chip_two_piece_bonuses')) return twoPiece;
|
||||
return null;
|
||||
}),
|
||||
all: vi.fn(async () => {
|
||||
if (sql.includes('FROM skill_chip_talent_effects'))
|
||||
return talentEffects;
|
||||
return [];
|
||||
}),
|
||||
run: vi.fn(async () => {
|
||||
if (sql.includes('DELETE FROM conditional_effect_rules'))
|
||||
mocks.events.push('delete conditional rule');
|
||||
if (sql.includes('DELETE FROM skill_chip_talents')) {
|
||||
mocks.events.push('delete talent');
|
||||
mocks.talentExists = false;
|
||||
}
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('requires explicit confirmation before removing an existing Talent', async () => {
|
||||
const result = await runSave();
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 400,
|
||||
data: {
|
||||
skillChipError:
|
||||
'Confirm removal of the existing 3-piece Talent and mechanic program.',
|
||||
},
|
||||
});
|
||||
expect(mocks.database.exec).not.toHaveBeenCalled();
|
||||
expect(mocks.events).not.toContain('delete conditional rule');
|
||||
expect(mocks.events).not.toContain('delete talent');
|
||||
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes the Talent and mechanic in one audited transaction after confirmation', async () => {
|
||||
const result = await runSave(true);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
skillChipSaved: true,
|
||||
skillChipMode: 'edit',
|
||||
resultToken: expect.any(String),
|
||||
});
|
||||
expect(mocks.events).toEqual([
|
||||
'BEGIN',
|
||||
'delete conditional rule',
|
||||
'delete talent',
|
||||
'audit',
|
||||
'COMMIT',
|
||||
]);
|
||||
expect(mocks.recordIsacAudit).toHaveBeenCalledWith({
|
||||
actorId: 'operator-1',
|
||||
category: 'game_data',
|
||||
action: 'skill_chip.updated',
|
||||
entityId: 'set-1',
|
||||
before: {
|
||||
record: beforeRecord,
|
||||
icon: null,
|
||||
render: null,
|
||||
twoPiece,
|
||||
talent: existingTalent,
|
||||
talentEffects,
|
||||
conditional,
|
||||
},
|
||||
after: {
|
||||
record: afterRecord,
|
||||
icon: null,
|
||||
render: null,
|
||||
twoPiece,
|
||||
talent: null,
|
||||
talentEffects: [],
|
||||
conditional: null,
|
||||
},
|
||||
database: mocks.database,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -211,7 +211,7 @@
|
||||
nodeEditing = null;
|
||||
};
|
||||
const editSpecialization = (item: any) => {
|
||||
specializationEditing = { id: item.id, name: item.name, description: item.description ?? '', active: Number(item.active), iconUrl: item.iconUrl ?? '' };
|
||||
specializationEditing = { id: item.id, name: item.name, description: item.description ?? '', active: Number(item.active), iconUrl: item.iconUrl ?? '', renderUrl: item.renderUrl ?? '' };
|
||||
focusEditing = abilityEditing = nodeEditing = null;
|
||||
};
|
||||
const editFocus = (item: any) => {
|
||||
@@ -249,7 +249,8 @@
|
||||
} : blankAbility(item.specialization_id).attack,
|
||||
effects: (item.effects ?? []).map(normalizedEffect),
|
||||
active: Number(item.active),
|
||||
iconUrl: item.iconUrl ?? ''
|
||||
iconUrl: item.iconUrl ?? '',
|
||||
renderUrl: item.renderUrl ?? ''
|
||||
};
|
||||
specializationEditing = focusEditing = nodeEditing = null;
|
||||
combatEffectEditing = null;
|
||||
@@ -284,7 +285,8 @@
|
||||
costs: { ...(rank.costs ?? {}) }
|
||||
})),
|
||||
active: Number(item.active),
|
||||
iconUrl: item.iconUrl ?? ''
|
||||
iconUrl: item.iconUrl ?? '',
|
||||
renderUrl: item.renderUrl ?? ''
|
||||
};
|
||||
specializationEditing = focusEditing = abilityEditing = null;
|
||||
};
|
||||
|
||||
@@ -21,18 +21,29 @@ import {
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import {
|
||||
MOD_QUALITIES,
|
||||
normalizeModSecondaryPool,
|
||||
} from '$lib/mods/secondary-pool';
|
||||
import {
|
||||
getModSecondaryPool,
|
||||
replaceModSecondaryPool,
|
||||
} from '$lib/server/mod-secondary-pools';
|
||||
import {
|
||||
applyIconMutation,
|
||||
gameDataIconMetadata,
|
||||
gameDataIconUrl,
|
||||
GameDataIconValidationError,
|
||||
readIconMutation,
|
||||
} from '$lib/server/game-data-icons';
|
||||
import {
|
||||
WEAPON_MOD_SLOT_DEFINITIONS,
|
||||
WEAPON_MOD_SLOT_TYPES,
|
||||
weaponModSlotDefinitionById,
|
||||
} from '$lib/weapons/mod-slots';
|
||||
|
||||
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'] as const;
|
||||
const slots = [
|
||||
'Optics',
|
||||
'Muzzle',
|
||||
'Underbarrel',
|
||||
'Magazine',
|
||||
'Barrel',
|
||||
'Upper Receiver',
|
||||
'Lower Receiver',
|
||||
'Trigger',
|
||||
] as const;
|
||||
const qualities = MOD_QUALITIES;
|
||||
const slots = WEAPON_MOD_SLOT_TYPES;
|
||||
const weaponCategories = [
|
||||
'shotgun',
|
||||
'rifle',
|
||||
@@ -49,10 +60,6 @@ type QualityValue = {
|
||||
primaryLevel20Max: number;
|
||||
};
|
||||
type Effect = { statId: string; values: Record<string, number> };
|
||||
type SecondaryRange = {
|
||||
statId: string;
|
||||
values: Record<string, { min: number; max: number }>;
|
||||
};
|
||||
type ExoticAttribute = {
|
||||
statId: string;
|
||||
level1Value: number;
|
||||
@@ -86,16 +93,6 @@ async function snapshot(id: string) {
|
||||
'SELECT * FROM weapon_mod_quality_values WHERE weapon_mod_id=? ORDER BY quality',
|
||||
)
|
||||
).all(id),
|
||||
secondaryOptions: await (
|
||||
await database.prepare(
|
||||
'SELECT stat_attribute_id FROM weapon_mod_secondary_options WHERE weapon_mod_id=? ORDER BY stat_attribute_id',
|
||||
)
|
||||
).all(id),
|
||||
secondaryQualityValues: await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM weapon_mod_secondary_quality_values WHERE weapon_mod_id=? ORDER BY stat_attribute_id,quality',
|
||||
)
|
||||
).all(id),
|
||||
exoticAttributes: await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM weapon_mod_exotic_attributes WHERE weapon_mod_id=? ORDER BY sort_order',
|
||||
@@ -146,10 +143,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
asc: 'CASE WHEN mods.exotic_weapon_id IS NULL THEN 0 ELSE 1 END ASC,mods.name COLLATE NOCASE ASC',
|
||||
desc: 'CASE WHEN mods.exotic_weapon_id IS NULL THEN 0 ELSE 1 END DESC,mods.name COLLATE NOCASE ASC',
|
||||
},
|
||||
secondaries: {
|
||||
asc: '(SELECT COUNT(*) FROM weapon_mod_secondary_options option WHERE option.weapon_mod_id=mods.id) ASC,mods.name COLLATE NOCASE ASC',
|
||||
desc: '(SELECT COUNT(*) FROM weapon_mod_secondary_options option WHERE option.weapon_mod_id=mods.id) DESC,mods.name COLLATE NOCASE ASC',
|
||||
},
|
||||
compatibility: {
|
||||
asc: "COALESCE(weapons.name,(SELECT MIN(link.weapon_category) FROM weapon_mod_compatible_categories link WHERE link.weapon_mod_id=mods.id),'') COLLATE NOCASE ASC,mods.name COLLATE NOCASE ASC",
|
||||
desc: "COALESCE(weapons.name,(SELECT MIN(link.weapon_category) FROM weapon_mod_compatible_categories link WHERE link.weapon_mod_id=mods.id),'') COLLATE NOCASE DESC,mods.name COLLATE NOCASE ASC",
|
||||
@@ -229,7 +222,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
'exoticWeaponId',
|
||||
'compatibleCategories',
|
||||
'qualityValues',
|
||||
'secondaryRanges',
|
||||
'effects',
|
||||
'exoticAttributes',
|
||||
],
|
||||
@@ -260,14 +252,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
mods,
|
||||
compatibility,
|
||||
qualityRows,
|
||||
secondaryOptions,
|
||||
secondaryValues,
|
||||
exoticAttributes,
|
||||
effects,
|
||||
effectValues,
|
||||
stats,
|
||||
weapons,
|
||||
renders,
|
||||
slotIcons,
|
||||
secondaryPool,
|
||||
] = await Promise.all([
|
||||
(
|
||||
await database.prepare(`SELECT mods.*,weapons.name AS exotic_weapon_name FROM weapon_mods mods
|
||||
@@ -278,14 +270,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await database.prepare('SELECT * FROM weapon_mod_compatible_categories')
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM weapon_mod_quality_values')).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM weapon_mod_secondary_options')
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM weapon_mod_secondary_quality_values',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM weapon_mod_exotic_attributes ORDER BY sort_order',
|
||||
@@ -314,6 +298,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='weapon_mod_render'",
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='weapon_mod_slot'",
|
||||
)
|
||||
).all(),
|
||||
getModSecondaryPool(database, 'weapon'),
|
||||
]);
|
||||
const availableWeapons = [
|
||||
...pendingWeapons.map((item) => ({
|
||||
@@ -331,7 +321,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
?.name ?? '',
|
||||
compatibleCategories: item.compatibleCategories,
|
||||
qualityValues: parseJson(String(item.qualityValues), []),
|
||||
secondaryRanges: parseJson(String(item.secondaryRanges), []),
|
||||
exoticAttributes: parseJson(String(item.exoticAttributes), []),
|
||||
effects: parseJson(String(item.effects), []),
|
||||
}));
|
||||
@@ -352,23 +341,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
primaryLevel20Min: row.primary_level_20_min,
|
||||
primaryLevel20Max: row.primary_level_20_max,
|
||||
})),
|
||||
secondaryRanges: secondaryOptions
|
||||
.filter((option) => option.weapon_mod_id === mod.id)
|
||||
.map((option) => ({
|
||||
statId: String(option.stat_attribute_id),
|
||||
values: Object.fromEntries(
|
||||
secondaryValues
|
||||
.filter(
|
||||
(row) =>
|
||||
row.weapon_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) },
|
||||
]),
|
||||
),
|
||||
})),
|
||||
exoticAttributes: exoticAttributes
|
||||
.filter((row) => row.weapon_mod_id === mod.id)
|
||||
.map((row) => ({
|
||||
@@ -396,9 +368,19 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
})(),
|
||||
})),
|
||||
],
|
||||
secondaryPool,
|
||||
stats: [...pendingStats, ...stats],
|
||||
weapons: availableWeapons,
|
||||
slots: slots.map((name) => ({ id: name, name })),
|
||||
slots: WEAPON_MOD_SLOT_DEFINITIONS.map((definition) => {
|
||||
const icon = slotIcons.find((row) => row.entity_id === definition.id);
|
||||
return {
|
||||
...definition,
|
||||
iconUrl: icon
|
||||
? gameDataIconUrl('weapon_mod_slot', definition.id, icon.updated_at)
|
||||
: '',
|
||||
updatedAt: icon ? String(icon.updated_at) : '',
|
||||
};
|
||||
}),
|
||||
qualities,
|
||||
weaponCategories: weaponCategories.map((name) => ({
|
||||
id: name,
|
||||
@@ -418,6 +400,141 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
saveSlotIcon: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const slotType = String(form.get('slotType') ?? '');
|
||||
const definition = weaponModSlotDefinitionById(id);
|
||||
if (!definition || definition.name !== slotType) {
|
||||
return fail(400, {
|
||||
weaponModSlotIconError: 'Choose a valid Weapon Mod slot icon target.',
|
||||
});
|
||||
}
|
||||
let mutation: Awaited<ReturnType<typeof readIconMutation>>;
|
||||
try {
|
||||
mutation = await readIconMutation(form);
|
||||
} catch (error) {
|
||||
if (error instanceof GameDataIconValidationError)
|
||||
return fail(400, { weaponModSlotIconError: error.message });
|
||||
throw error;
|
||||
}
|
||||
if (!mutation.output && !mutation.remove) {
|
||||
return fail(400, {
|
||||
weaponModSlotIconError:
|
||||
'Choose an icon to upload or remove the current icon.',
|
||||
});
|
||||
}
|
||||
const database = await getDatabase();
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const beforeIcon = await gameDataIconMetadata(
|
||||
database,
|
||||
'weapon_mod_slot',
|
||||
definition.id,
|
||||
);
|
||||
const baseUpdatedAt = String(form.get('baseUpdatedAt') ?? '');
|
||||
if (String(beforeIcon?.updated_at ?? '') !== baseUpdatedAt) {
|
||||
await database.exec('ROLLBACK');
|
||||
return fail(409, {
|
||||
weaponModSlotIconError:
|
||||
'This slot icon changed after the editor opened. Review the latest icon and try again.',
|
||||
});
|
||||
}
|
||||
if (mutation.remove && !beforeIcon) {
|
||||
await database.exec('ROLLBACK');
|
||||
return fail(400, {
|
||||
weaponModSlotIconError: 'This slot does not have an icon to remove.',
|
||||
});
|
||||
}
|
||||
await applyIconMutation(
|
||||
database,
|
||||
'weapon_mod_slot',
|
||||
definition.id,
|
||||
mutation,
|
||||
operator.id,
|
||||
);
|
||||
const afterIcon = await gameDataIconMetadata(
|
||||
database,
|
||||
'weapon_mod_slot',
|
||||
definition.id,
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'weapon_mod_slot_icon.updated',
|
||||
entityId: definition.id,
|
||||
before: { slot: definition, icon: beforeIcon },
|
||||
after: { slot: definition, icon: afterIcon },
|
||||
database,
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
weaponModSlotIconSaved: true,
|
||||
resultToken: randomUUID(),
|
||||
};
|
||||
},
|
||||
saveSecondaryPool: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const form = await request.formData();
|
||||
if (String(form.get('id') ?? '') !== 'weapon-mod-secondary-pool') {
|
||||
return fail(400, {
|
||||
weaponModSecondaryPoolError:
|
||||
'The shared Weapon Mod pool target is invalid.',
|
||||
});
|
||||
}
|
||||
const normalized = normalizeModSecondaryPool(
|
||||
parseJson<unknown>(form.get('secondaryRanges'), null),
|
||||
);
|
||||
if (normalized.error) {
|
||||
return fail(400, { weaponModSecondaryPoolError: 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, {
|
||||
weaponModSecondaryPoolError:
|
||||
'One or more selected Attributes are unavailable.',
|
||||
});
|
||||
}
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const before = await getModSecondaryPool(database, 'weapon');
|
||||
await replaceModSecondaryPool(database, 'weapon', normalized.pool);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'weapon_mod_secondary_pool.updated',
|
||||
entityId: 'weapon-mod-secondary-pool',
|
||||
before,
|
||||
after: await getModSecondaryPool(database, 'weapon'),
|
||||
database,
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
weaponModSecondaryPoolSaved: true,
|
||||
weaponModSecondaryPoolMode: 'edit',
|
||||
resultToken: randomUUID(),
|
||||
};
|
||||
},
|
||||
save: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
@@ -442,6 +559,12 @@ export const actions: Actions = {
|
||||
const name = String(form.get('name') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
if (form.has('secondaryRanges')) {
|
||||
return fail(400, {
|
||||
weaponModError:
|
||||
'Secondary Attributes must be submitted through the shared pool editor.',
|
||||
});
|
||||
}
|
||||
const slotType = String(form.get('slotType') ?? '');
|
||||
const isExotic = form.get('isExotic') === '1';
|
||||
const exoticWeaponId = String(form.get('exoticWeaponId') ?? '');
|
||||
@@ -454,10 +577,6 @@ export const actions: Actions = {
|
||||
form.get('qualityValues'),
|
||||
[],
|
||||
);
|
||||
const secondaryRanges = parseJson<SecondaryRange[]>(
|
||||
form.get('secondaryRanges'),
|
||||
[],
|
||||
);
|
||||
const effects = parseJson<Effect[]>(form.get('effects'), []);
|
||||
const exoticAttributes = parseJson<ExoticAttribute[]>(
|
||||
form.get('exoticAttributes'),
|
||||
@@ -478,30 +597,6 @@ export const actions: Actions = {
|
||||
weaponModError:
|
||||
'Exotic weapon assignment is only available for Exotic Weapon Mods.',
|
||||
});
|
||||
if (
|
||||
!isExotic &&
|
||||
(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, {
|
||||
weaponModError:
|
||||
'Configure at least two unique possible secondary attributes with valid ranges for every quality.',
|
||||
});
|
||||
if (isExotic && compatibleCategories.length)
|
||||
return fail(400, {
|
||||
weaponModError:
|
||||
@@ -599,10 +694,7 @@ export const actions: Actions = {
|
||||
...new Set(
|
||||
isExotic
|
||||
? exoticAttributes.map((attribute) => attribute.statId)
|
||||
: [
|
||||
...secondaryRanges.map((range) => range.statId),
|
||||
...effects.map((effect) => effect.statId),
|
||||
],
|
||||
: effects.map((effect) => effect.statId),
|
||||
),
|
||||
];
|
||||
const validStats = await (
|
||||
@@ -671,29 +763,6 @@ export const actions: Actions = {
|
||||
row.primaryLevel20Min,
|
||||
row.primaryLevel20Max,
|
||||
);
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM weapon_mod_secondary_options WHERE weapon_mod_id=?',
|
||||
)
|
||||
).run(modId);
|
||||
for (const range of isExotic ? [] : secondaryRanges) {
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO weapon_mod_secondary_options(weapon_mod_id,stat_attribute_id) VALUES(?,?)',
|
||||
)
|
||||
).run(modId, range.statId);
|
||||
for (const quality of qualities)
|
||||
await (
|
||||
await database.prepare(`INSERT INTO weapon_mod_secondary_quality_values
|
||||
(weapon_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 (
|
||||
await database.prepare(
|
||||
'DELETE FROM weapon_mod_effects WHERE weapon_mod_id=?',
|
||||
|
||||
@@ -9,19 +9,20 @@
|
||||
import GameIcon from '$lib/components/GameIcon.svelte';
|
||||
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
|
||||
import type { GameIconName } from '$lib/game-icons';
|
||||
import ModSecondaryPoolEditor from '$lib/components/ModSecondaryPoolEditor.svelte';
|
||||
import WeaponModSlotIconPanel from '$lib/components/WeaponModSlotIconPanel.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
|
||||
const qualityRow = (quality: string) => ({
|
||||
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 blankEffect = () => ({ statId: '', values: Object.fromEntries(data.qualities.map((quality: string) => [quality, 0])) });
|
||||
const blankExoticAttribute = () => ({ statId:'',level1Value:0,level20Value:0 });
|
||||
const blank = () => ({
|
||||
id: '', name: '', active: 1, slot_type: '', exotic_weapon_id: '', renderUrl: '',
|
||||
compatibleCategories: [], qualityValues: data.qualities.map(qualityRow),
|
||||
secondaryRanges: [blankSecondary(),blankSecondary()], effects: [blankEffect()], exoticAttributes:[blankExoticAttribute()]
|
||||
effects: [blankEffect()], exoticAttributes:[blankExoticAttribute()]
|
||||
});
|
||||
let editing = $state<any | null>(null);
|
||||
let levelPreview = $state(1);
|
||||
@@ -31,9 +32,8 @@
|
||||
let renderResetToken = $state(0);
|
||||
let openedRequestedItem = $state(false);
|
||||
const categoryOptions = $derived(data.weaponCategories.map((category: any) => ({ value:category.id,label:category.name,icon:category.id as GameIconName,iconOnly:true })));
|
||||
const slotOptions = $derived(data.slots.map((slot: any) => ({ value:slot.id,label:slot.name.toUpperCase() })));
|
||||
const slotOptions = $derived(data.slots.map((slot: any) => ({ value:slot.name,label:slot.name.toUpperCase() })));
|
||||
const exoticWeapons = $derived(data.weapons.filter((weapon: any) => Number(weapon.is_exotic)));
|
||||
const stat = (id: string) => data.stats.find((item: any) => item.id === id);
|
||||
const weapon = (id: string) => data.weapons.find((item: any) => item.id === id);
|
||||
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;
|
||||
@@ -43,7 +43,6 @@
|
||||
...item,
|
||||
compatibleCategories: [...item.compatibleCategories],
|
||||
qualityValues: data.qualities.map((quality: string) => ({ ...qualityRow(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()],
|
||||
exoticAttributes: item.exoticAttributes.length ? item.exoticAttributes.map((attribute: any)=>({...attribute})) : [blankExoticAttribute()],
|
||||
effects: item.effects.length ? item.effects.map((effect: any) => ({ statId: effect.statId, values: { ...effect.values } })) : [blankEffect()]
|
||||
};
|
||||
@@ -58,8 +57,6 @@
|
||||
};
|
||||
const addEffect = () => { if (editing.effects.length < 2) editing.effects = [...editing.effects, blankEffect()]; };
|
||||
const removeEffect = (index: number) => { if (editing.effects.length > 1) editing.effects = editing.effects.filter((_: any, itemIndex: number) => itemIndex !== index); };
|
||||
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); };
|
||||
const addExoticAttribute = () => { if(editing.exoticAttributes.length<12) editing.exoticAttributes=[...editing.exoticAttributes,blankExoticAttribute()]; };
|
||||
const removeExoticAttribute = (index:number) => { if(editing.exoticAttributes.length>1) editing.exoticAttributes=editing.exoticAttributes.filter((_:any,itemIndex:number)=>itemIndex!==index); };
|
||||
$effect(() => {
|
||||
@@ -81,6 +78,9 @@
|
||||
<svelte:head><title>Weapon Mods — ISAC</title></svelte:head>
|
||||
<div class="module-toolbar"><div><p class="eyebrow">ARMAMENT MODIFICATION REGISTRY</p><h2>WEAPON MODS</h2><p>Configure tier-aware slot compatibility, quality-scaled rolls, and fixed Mod Effects.</p></div><button class="primary" onclick={() => { editing = blank(); isExotic = false; }}>ADD WEAPON MOD <span>+</span></button></div>
|
||||
|
||||
<WeaponModSlotIconPanel slots={data.slots} error={form?.weaponModSlotIconError} saved={form?.weaponModSlotIconSaved} resultToken={form?.resultToken}/>
|
||||
<ModSecondaryPoolEditor kind="Weapon Mod" pool={data.secondaryPool} stats={data.stats} error={form?.weaponModSecondaryPoolError} saved={form?.weaponModSecondaryPoolSaved} resultToken={form?.resultToken}/>
|
||||
|
||||
<KpiFilters active={data.filter} items={[
|
||||
{value:'',label:'TOTAL MODS',count:data.summary.total,description:'Show all definitions'},
|
||||
{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to weapons'},
|
||||
@@ -96,7 +96,6 @@
|
||||
<input type="hidden" name="id" value={editing.id}/>
|
||||
<input type="hidden" name="isExotic" value={isExotic ? '1' : '0'}/>
|
||||
<input type="hidden" name="qualityValues" value={JSON.stringify(editing.qualityValues)}/>
|
||||
<input type="hidden" name="secondaryRanges" value={JSON.stringify(editing.secondaryRanges)}/>
|
||||
<input type="hidden" name="effects" value={JSON.stringify(editing.effects)}/>
|
||||
<input type="hidden" name="exoticAttributes" value={JSON.stringify(editing.exoticAttributes)}/>
|
||||
<div class="weapon-mod-identity">
|
||||
@@ -130,16 +129,7 @@
|
||||
</div>
|
||||
<div class="weapon-mod-preview">
|
||||
<div><label>LEVEL PREVIEW <b>{levelPreview}</b><input type="range" min="1" max="20" step="1" bind:value={levelPreview}/></label><SegmentedChoice legend="QUALITY PREVIEW" name="qualityPreview" options={data.qualities.map((quality: string) => ({ value:quality,label:quality }))} bind:value={qualityPreview}/></div>
|
||||
{#if previewRow}<div class="weapon-mod-preview-values"><span><div class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></div><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></div>{/if}
|
||||
</div>
|
||||
</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}
|
||||
{#if previewRow}<div class="weapon-mod-preview-values"><span><div class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></div><small>SELECTED PRIMARY · LEVEL {levelPreview}</small><b>{display(interpolate(previewRow.primaryLevel1Min,previewRow.primaryLevel20Min))}–{display(interpolate(previewRow.primaryLevel1Max,previewRow.primaryLevel20Max))}</b></span><span><small>SHARED SECONDARY POOL</small><b>{data.secondaryPool.length} OPTIONS</b></span></div>{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -166,9 +156,9 @@
|
||||
|
||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||
<div class="dashboard-table weapon-mod-dashboard-table">
|
||||
<div class="dashboard-row weapon-mod-row table-heading"><SortableColumnHeader label="WEAPON MOD" column="name" sort={data.sort}/><SortableColumnHeader label="SLOT" column="slot" sort={data.sort}/><SortableColumnHeader label="PRIMARY" column="primary" sort={data.sort}/><SortableColumnHeader label="SECONDARIES" column="secondaries" sort={data.sort}/><SortableColumnHeader label="COMPATIBILITY" column="compatibility" sort={data.sort}/><SortableColumnHeader label="MOD EFFECT" column="effects" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
||||
<div class="dashboard-row weapon-mod-row table-heading"><SortableColumnHeader label="WEAPON MOD" column="name" sort={data.sort}/><SortableColumnHeader label="SLOT" column="slot" sort={data.sort}/><SortableColumnHeader label="PRIMARY" column="primary" sort={data.sort}/><span>SECONDARIES</span><SortableColumnHeader label="COMPATIBILITY" column="compatibility" sort={data.sort}/><SortableColumnHeader label="MOD EFFECT" column="effects" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
||||
{#each data.mods as item}
|
||||
<div class="dashboard-row weapon-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.exotic_weapon_id ? 'EXOTIC · PRE-EQUIPPED' : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div></div><span>{item.slot_type}</span><span class="weapon-mod-primary">{#if item.exotic_weapon_id}<GameIcon name="exotic" decorative/>FIXED{:else}<span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span>ANY CORE{/if}</span><span>{item.exotic_weapon_id ? `${item.exoticAttributes.length} FIXED` : `${item.secondaryRanges.length} POSSIBLE`}<small> · {item.exotic_weapon_id ? 'LEVEL SCALED' : 'SELECT 2 IN BUILDER'}</small></span><span>{item.exotic_weapon_id ? weapon(item.exotic_weapon_id)?.name ?? item.exotic_weapon_name : item.compatibleCategories.map((category: string) => category.toUpperCase()).join(' · ')}</span><span>{item.exotic_weapon_id ? 'NO ROLLS' : `${item.effects.length} EFFECT${item.effects.length===1?'':'S'}`}</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 onclick={() => edit(item)} aria-expanded={editing?.id === item.id} aria-controls={`weapon-mod-editor-${item.id}`}>EDIT</button>{/if}</div>
|
||||
<div class="dashboard-row weapon-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.exotic_weapon_id ? 'EXOTIC · PRE-EQUIPPED' : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div></div><span>{item.slot_type}</span><span class="weapon-mod-primary">{#if item.exotic_weapon_id}<GameIcon name="exotic" decorative/>FIXED{:else}<span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span>ANY CORE{/if}</span><span>{item.exotic_weapon_id ? `${item.exoticAttributes.length} FIXED` : `${data.secondaryPool.length} SHARED`}<small> · {item.exotic_weapon_id ? 'LEVEL SCALED' : '0 / 1 / 2 / 2'}</small></span><span>{item.exotic_weapon_id ? weapon(item.exotic_weapon_id)?.name ?? item.exotic_weapon_name : item.compatibleCategories.map((category: string) => category.toUpperCase()).join(' · ')}</span><span>{item.exotic_weapon_id ? 'NO ROLLS' : `${item.effects.length} EFFECT${item.effects.length===1?'':'S'}`}</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 onclick={() => edit(item)} aria-expanded={editing?.id === item.id} aria-controls={`weapon-mod-editor-${item.id}`}>EDIT</button>{/if}</div>
|
||||
{#if editing?.id === item.id}
|
||||
<RegistryInlineEditor id={`weapon-mod-editor-${item.id}`} label={`Edit ${item.name}`}>
|
||||
{@render weaponModEditor()}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
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(),
|
||||
recordIsacAudit: 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: mocks.recordIsacAudit,
|
||||
}));
|
||||
|
||||
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),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const request = (secondaryRanges: ReturnType<typeof pool>) => {
|
||||
const form = new FormData();
|
||||
form.set('id', 'weapon-mod-secondary-pool');
|
||||
form.set('secondaryRanges', JSON.stringify(secondaryRanges));
|
||||
return new Request('http://localhost/isac/weaponmods?/saveSecondaryPool', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
};
|
||||
|
||||
describe('Weapon Mod shared secondary pool action', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.audits.length = 0;
|
||||
mocks.recordIsacAudit.mockImplementation(async (event) => {
|
||||
mocks.audits.push(event);
|
||||
});
|
||||
});
|
||||
|
||||
it('audits the complete contract and commits the transaction', async () => {
|
||||
const before = pool('before');
|
||||
const after = pool('after', 1);
|
||||
mocks.getModSecondaryPool
|
||||
.mockResolvedValueOnce(before)
|
||||
.mockResolvedValueOnce(after);
|
||||
|
||||
const result = await actions.saveSecondaryPool?.({
|
||||
cookies: {} as never,
|
||||
request: request(after),
|
||||
} as never);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
weaponModSecondaryPoolSaved: true,
|
||||
weaponModSecondaryPoolMode: 'edit',
|
||||
resultToken: expect.any(String),
|
||||
});
|
||||
expect(mocks.requirePermission).toHaveBeenCalledWith(
|
||||
{ id: 'operator-1' },
|
||||
'data.manage',
|
||||
);
|
||||
expect(mocks.replaceModSecondaryPool).toHaveBeenCalledWith(
|
||||
mocks.database,
|
||||
'weapon',
|
||||
after,
|
||||
);
|
||||
expect(mocks.audits).toEqual([
|
||||
{
|
||||
actorId: 'operator-1',
|
||||
category: 'game_data',
|
||||
action: 'weapon_mod_secondary_pool.updated',
|
||||
entityId: 'weapon-mod-secondary-pool',
|
||||
before,
|
||||
after,
|
||||
database: mocks.database,
|
||||
},
|
||||
]);
|
||||
expect(mocks.database.exec.mock.calls).toEqual([
|
||||
['BEGIN IMMEDIATE'],
|
||||
['COMMIT'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('rolls back without committing when the audit write fails', async () => {
|
||||
const before = pool('before');
|
||||
const after = pool('after', 1);
|
||||
const auditError = new Error('audit insert failed');
|
||||
mocks.getModSecondaryPool
|
||||
.mockResolvedValueOnce(before)
|
||||
.mockResolvedValueOnce(after);
|
||||
mocks.recordIsacAudit.mockRejectedValueOnce(auditError);
|
||||
|
||||
await expect(
|
||||
actions.saveSecondaryPool?.({
|
||||
cookies: {} as never,
|
||||
request: request(after),
|
||||
} as never),
|
||||
).rejects.toThrow(auditError);
|
||||
|
||||
expect(mocks.recordIsacAudit).toHaveBeenCalledOnce();
|
||||
expect(mocks.database.exec).toHaveBeenCalledWith('ROLLBACK');
|
||||
expect(mocks.database.exec).not.toHaveBeenCalledWith('COMMIT');
|
||||
expect(mocks.database.exec.mock.calls).toEqual([
|
||||
['BEGIN IMMEDIATE'],
|
||||
['ROLLBACK'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { WEAPON_MOD_SLOT_DEFINITIONS } from '$lib/weapons/mod-slots';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
applyIconMutation: vi.fn(),
|
||||
database: {
|
||||
exec: vi.fn(async () => undefined),
|
||||
prepare: vi.fn(async () => ({ get: vi.fn(), all: vi.fn() })),
|
||||
},
|
||||
gameDataIconMetadata: vi.fn(),
|
||||
readIconMutation: vi.fn(),
|
||||
recordIsacAudit: 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: mocks.recordIsacAudit,
|
||||
}));
|
||||
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/game-data-icons', () => ({
|
||||
applyIconMutation: mocks.applyIconMutation,
|
||||
gameDataIconMetadata: mocks.gameDataIconMetadata,
|
||||
gameDataIconUrl: vi.fn(() => ''),
|
||||
GameDataIconValidationError: class extends Error {},
|
||||
readIconMutation: mocks.readIconMutation,
|
||||
}));
|
||||
vi.mock('$lib/server/mod-secondary-pools', () => ({
|
||||
getModSecondaryPool: vi.fn(),
|
||||
replaceModSecondaryPool: vi.fn(),
|
||||
}));
|
||||
|
||||
import { actions } from './+page.server';
|
||||
|
||||
const definition = WEAPON_MOD_SLOT_DEFINITIONS[0];
|
||||
|
||||
function actionRequest(overrides: Record<string, string> = {}) {
|
||||
const form = new FormData();
|
||||
form.set('id', overrides.id ?? definition.id);
|
||||
form.set('name', `${definition.name} Slot`);
|
||||
form.set('slotType', overrides.slotType ?? definition.name);
|
||||
form.set('baseUpdatedAt', overrides.baseUpdatedAt ?? 'before-version');
|
||||
return new Request('http://localhost/isac/weaponmods?/saveSlotIcon', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
|
||||
const invoke = (request = actionRequest()) =>
|
||||
actions.saveSlotIcon?.({ cookies: {} as never, request } as never);
|
||||
|
||||
describe('Weapon Mod shared slot icon action', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.gameDataIconMetadata.mockReset();
|
||||
mocks.readIconMutation.mockReset();
|
||||
mocks.recordIsacAudit.mockReset();
|
||||
mocks.readIconMutation.mockResolvedValue({
|
||||
output: Buffer.from('icon'),
|
||||
remove: false,
|
||||
});
|
||||
mocks.gameDataIconMetadata
|
||||
.mockResolvedValueOnce({ byte_size: 10, updated_at: 'before-version' })
|
||||
.mockResolvedValueOnce({ byte_size: 20, updated_at: 'after-version' });
|
||||
});
|
||||
|
||||
it('validates the target, saves, audits, and commits', async () => {
|
||||
const result = await invoke();
|
||||
|
||||
expect(result).toMatchObject({
|
||||
weaponModSlotIconSaved: true,
|
||||
resultToken: expect.any(String),
|
||||
});
|
||||
expect(mocks.requirePermission).toHaveBeenCalledWith(
|
||||
{ id: 'operator-1' },
|
||||
'data.manage',
|
||||
);
|
||||
expect(mocks.applyIconMutation).toHaveBeenCalledWith(
|
||||
mocks.database,
|
||||
'weapon_mod_slot',
|
||||
definition.id,
|
||||
{ output: Buffer.from('icon'), remove: false },
|
||||
'operator-1',
|
||||
);
|
||||
expect(mocks.recordIsacAudit).toHaveBeenCalledWith({
|
||||
actorId: 'operator-1',
|
||||
category: 'game_data',
|
||||
action: 'weapon_mod_slot_icon.updated',
|
||||
entityId: definition.id,
|
||||
before: {
|
||||
slot: definition,
|
||||
icon: { byte_size: 10, updated_at: 'before-version' },
|
||||
},
|
||||
after: {
|
||||
slot: definition,
|
||||
icon: { byte_size: 20, updated_at: 'after-version' },
|
||||
},
|
||||
database: mocks.database,
|
||||
});
|
||||
expect(mocks.database.exec.mock.calls).toEqual([
|
||||
['BEGIN IMMEDIATE'],
|
||||
['COMMIT'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a mismatched slot identity before reading the upload', async () => {
|
||||
const result = await invoke(actionRequest({ slotType: 'Muzzle' }));
|
||||
|
||||
expect(result).toMatchObject({ status: 400 });
|
||||
expect(mocks.readIconMutation).not.toHaveBeenCalled();
|
||||
expect(mocks.database.exec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects stale editor data without changing the icon', async () => {
|
||||
const result = await invoke(
|
||||
actionRequest({ baseUpdatedAt: 'stale-version' }),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ status: 409 });
|
||||
expect(mocks.applyIconMutation).not.toHaveBeenCalled();
|
||||
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
|
||||
expect(mocks.database.exec.mock.calls).toEqual([
|
||||
['BEGIN IMMEDIATE'],
|
||||
['ROLLBACK'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('rolls back when audit persistence fails', async () => {
|
||||
const auditError = new Error('audit failed');
|
||||
mocks.recordIsacAudit.mockRejectedValueOnce(auditError);
|
||||
|
||||
await expect(invoke()).rejects.toThrow(auditError);
|
||||
expect(mocks.database.exec.mock.calls).toEqual([
|
||||
['BEGIN IMMEDIATE'],
|
||||
['ROLLBACK'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,40 +1,133 @@
|
||||
# Game Data Capture Contracts
|
||||
|
||||
RG-1 freezes contract version 1 for the taxonomy, workflow states, evidence model, archive manifest, and ISAC handoff boundary. The approved GDC-001 mod-family change extends version 1 with `weapon_mod` and `gear_mod`.
|
||||
RG-1 freezes contract version 1 for the taxonomy, workflow states, evidence
|
||||
model, archive manifest, and ISAC handoff boundary. The approved GDC-001
|
||||
mod-family change extends version 1 with `weapon_mod` and `gear_mod`.
|
||||
|
||||
## Files
|
||||
|
||||
- `domain-contract-v1.json` lists every family/view pair, record type, Gear slot, abstention reason, state, and allowed transition.
|
||||
- `screenshot-classification.schema.json` validates one hierarchical screen classification.
|
||||
- `evidence-archive-manifest-v1.schema.json` validates a private evidence archive manifest.
|
||||
- `isac-handoff-v1.schema.json` validates a bounded draft handoff for authenticated ISAC intake.
|
||||
- `examples/classification/` contains one valid and one invalid classification for each family, including Weapon Mod, Gear Mod, and explicit `unknown` abstention.
|
||||
- `examples/archive/` and `examples/handoff/` exercise each versioned document schema.
|
||||
- `domain-contract-v1.json` lists every family/view pair, record type, Gear
|
||||
slot, abstention reason, state, and allowed transition.
|
||||
- `screenshot-classification.schema.json` validates one hierarchical screen
|
||||
classification.
|
||||
- `evidence-archive-manifest-v1.schema.json` validates a private evidence
|
||||
archive manifest.
|
||||
- `isac-handoff-v1.schema.json` validates a bounded draft handoff for
|
||||
authenticated ISAC intake.
|
||||
- `examples/classification/` contains one valid and one invalid classification
|
||||
for each family, including Weapon Mod, Gear Mod, and explicit `unknown`
|
||||
abstention.
|
||||
- `examples/archive/` and `examples/handoff/` exercise each versioned document
|
||||
schema.
|
||||
|
||||
The schemas use JSON Schema Draft 2020-12, internal `$ref` values, standard formats, and no implementation-specific keywords. Python `jsonschema` and TypeScript validators such as Ajv can compile the files without model conversion. Consumers must reject a document unless `schema_version` equals the schema's `const` value. Consumers must also run semantic reference validation because JSON Schema cannot prove that an ID points to an item in another array.
|
||||
The schemas use JSON Schema Draft 2020-12, internal `$ref` values, standard
|
||||
formats, and no implementation-specific keywords. Python `jsonschema` and
|
||||
TypeScript validators such as Ajv can compile the files without model
|
||||
conversion. Consumers must reject a document unless `schema_version` equals the
|
||||
schema's `const` value. Consumers must also run semantic reference validation
|
||||
because JSON Schema cannot prove that an ID points to an item in another array.
|
||||
|
||||
## Mod Screen Semantics
|
||||
|
||||
Repository source data and editors establish `weapon_mod` and `gear_mod` as separate canonical aggregates. Each family currently supports only the `detail` view and its same-named record type. A Mod classification never sets the six-slot `gear_slot` field. The Weapon Mod slot, quality, compatibility, secondary-option, Mod Effect, exotic-attribute, and weapon-lock data and the Gear Mod quality and secondary-option data remain evidence-backed fields or relationships on the owning aggregate. They are not separate screen views or record types.
|
||||
Repository source data and editors establish `weapon_mod` and `gear_mod` as
|
||||
separate canonical aggregates. Each family currently supports only the `detail`
|
||||
view and its same-named record type. A Mod classification never sets the
|
||||
six-slot `gear_slot` field. A Weapon Mod detail screenshot may provide evidence
|
||||
for item-owned slot, primary quality ranges, compatibility, Mod Effects, exotic
|
||||
Attributes, and weapon-lock data. A Gear Mod detail screenshot may provide
|
||||
evidence for its item-owned primary quality ranges. These fields and
|
||||
relationships are not separate screen views or record types.
|
||||
|
||||
The `record_types` array still supports composite screens. No repository evidence establishes another independent canonical record type on a Weapon Mod or Gear Mod detail screen, so these two mappings each contain one record type. A classifier must use `unknown` with an abstention reason for another Mod view, an ambiguous family, or unsupported content.
|
||||
Each mod family has one global canonical secondary Attribute pool configured
|
||||
outside individual item details. The pool stores Level 1 and Level 20 minimum
|
||||
and maximum ranges for Specialized, Superior, and High-end quality. Capacity is
|
||||
zero for Standard, one for Specialized, and two each for Superior and High-end.
|
||||
A secondary roll visible on an item-detail screenshot is evidence for that
|
||||
observed item instance; it does not establish a pool option, canonical range
|
||||
endpoint, or family-wide capacity.
|
||||
|
||||
The `record_types` array still supports composite screens. No repository
|
||||
evidence establishes another independent canonical record type on a Weapon Mod
|
||||
or Gear Mod detail screen, so these two mappings each contain one record type. A
|
||||
classifier must use `unknown` with an abstention reason for another Mod view, an
|
||||
ambiguous family, or unsupported content.
|
||||
|
||||
## Skill Chip Screen Semantics
|
||||
|
||||
The implemented Skill Chip model assigns each definition to one specialization
|
||||
ability. Each set owns one 2-piece bonus and may own one 3-piece Talent. One
|
||||
shared fixed primary profile serves every definition: Skill Duration, Skill
|
||||
Radius, Skill Cooldown Recovery, and Skill Intensity each store direct minimum
|
||||
and maximum values for Standard, Specialized, Superior, and High-end. These
|
||||
primary values apply to the assigned ability. Current Agent and combat totals
|
||||
exclude them and present them as `NOT YET DEFINED`.
|
||||
|
||||
One shared secondary Attribute pool also serves every definition. Its entries
|
||||
store direct minimum and maximum values for Specialized, Superior, and High-end.
|
||||
Secondary capacity is zero for Standard, one for Specialized, and two each for
|
||||
Superior and High-end. Skill Chips have no item levels, so workers must not
|
||||
infer level curves or level-based endpoints from Skill Chip evidence.
|
||||
|
||||
Every equipped quality counts toward the two-piece threshold. Only equipped
|
||||
High-end chips increase `h` in `minimum + (maximum - minimum) * h / 3`, where
|
||||
`h` ranges from zero through three. The unchanged version 1 contract vocabulary
|
||||
uses `skill_chip_roll_pool` for captured roll-profile evidence. Handoff
|
||||
consumers must map that evidence to the shared primary profile or shared
|
||||
secondary pool and must not attach either profile to an individual definition.
|
||||
The JSON schemas remain unchanged.
|
||||
|
||||
## Version Decision
|
||||
|
||||
The approved additive taxonomy change remains schema version 1. The document shapes, trust boundary, bounds, and meanings of every existing value are unchanged, and all existing version 1 labels and documents remain valid. This utility and its payloads are prototype formats; no production-stable archive or handoff version has been declared. A version increment would force a document migration without representing a structural or semantic break.
|
||||
The approved additive taxonomy change remains schema version 1. The document
|
||||
shapes, trust boundary, bounds, and meanings of every existing value are
|
||||
unchanged, and all existing version 1 labels and documents remain valid. This
|
||||
utility and its payloads are prototype formats; no production-stable archive or
|
||||
handoff version has been declared. A version increment would force a document
|
||||
migration without representing a structural or semantic break.
|
||||
|
||||
Consumers must update their closed version 1 vocabularies before they accept Mod labels. Older consumers reject the new values, which keeps the boundary fail closed. Existing stored labels need validation against the revised vocabulary but no value rewrite. The capture-label UI, storage regression coverage, classifier, extraction dispatch, evidence export, and ISAC adapter mappings must consume both families and their exact `detail`/record-type pairs before their respective gates can pass. This contract change does not alter those downstream implementations.
|
||||
Consumers must update their closed version 1 vocabularies before they accept Mod
|
||||
labels. Older consumers reject the new values, which keeps the boundary fail
|
||||
closed. Existing stored labels need validation against the revised vocabulary
|
||||
but no value rewrite. The capture-label UI, storage regression coverage,
|
||||
classifier, extraction dispatch, evidence export, and ISAC adapter mappings must
|
||||
consume both families and their exact `detail`/record-type pairs before their
|
||||
respective gates can pass. This contract change does not alter those downstream
|
||||
implementations.
|
||||
|
||||
## Evidence Rules
|
||||
|
||||
Capture workers store source text, a normalized value, confidence, engine identity, an image digest, a bounded region, and append-only correction history for each evidence value. A null normalized value requires a `missing_reason`. A present normalized value requires a null `missing_reason`. Each correction's `before` state must match the preceding `after` state. Timestamps must increase, and the final `after` state must match the current evidence value.
|
||||
Capture workers store source text, a normalized value, confidence, engine
|
||||
identity, an image digest, a bounded region, and append-only correction history
|
||||
for each evidence value. A null normalized value requires a `missing_reason`. A
|
||||
present normalized value requires a null `missing_reason`. Each correction's
|
||||
`before` state must match the preceding `after` state. Timestamps must increase,
|
||||
and the final `after` state must match the current evidence value.
|
||||
|
||||
Workers record the configured item shown on screen under `observed_instances`, including its visible level, quality, tier, upgrade state, and values. Workers place proposed reusable definitions under `canonical_candidates` and handoff `records`. A worker must not copy an observed roll, relationship, mechanic, range endpoint, or option list into a canonical candidate unless evidence establishes that canonical claim.
|
||||
Workers record the configured item shown on screen under `observed_instances`,
|
||||
including visible values and any applicable quality, tier, upgrade state, or
|
||||
level. Skill Chip observations omit item level because Skill Chips have none.
|
||||
Workers place proposed reusable definitions under `canonical_candidates` and
|
||||
handoff `records`. For Weapon Mods, Gear Mods, and Skill Chips, item-detail
|
||||
evidence remains separate from each family’s shared canonical profiles. A worker
|
||||
must not copy an observed roll, relationship, mechanic, range endpoint, option
|
||||
list, or secondary capacity into a canonical candidate unless evidence
|
||||
establishes that canonical claim.
|
||||
|
||||
## Trust Boundary
|
||||
|
||||
Archive and handoff documents contain content hashes and logical artifact IDs. They exclude host paths and credentials. The handoff declares `mutation_authority: "none"`. Importing or inspecting it grants no permission and performs no mutation. An authenticated ISAC action must revalidate each draft and preserve contributor moderation and audit behavior.
|
||||
Archive and handoff documents contain content hashes and logical artifact IDs.
|
||||
They exclude host paths and credentials. The handoff declares
|
||||
`mutation_authority: "none"`. Importing or inspecting it grants no permission
|
||||
and performs no mutation. An authenticated ISAC action must revalidate each
|
||||
draft and preserve contributor moderation and audit behavior.
|
||||
|
||||
Archive images and handoff images allow at most 64 MiB. Private archive videos allow at most 16 GiB, matching the configured capture ceiling. Schema validation enforces media-specific byte limits. Semantic validation enforces unique entity and correction IDs, local references, evidence artifact hashes, correction chains, and normalized image bounds.
|
||||
Archive images and handoff images allow at most 64 MiB. Private archive videos
|
||||
allow at most 16 GiB, matching the configured capture ceiling. Schema validation
|
||||
enforces media-specific byte limits. Semantic validation enforces unique entity
|
||||
and correction IDs, local references, evidence artifact hashes, correction
|
||||
chains, and normalized image bounds.
|
||||
|
||||
The contracts assign explicit states or reasons to cancellation, resource exhaustion, ambiguous classification, unsupported content, and extraction failure. Recovery requires a listed transition. Unknown transitions and future schema versions fail closed.
|
||||
The contracts assign explicit states or reasons to cancellation, resource
|
||||
exhaustion, ambiguous classification, unsupported content, and extraction
|
||||
failure. Recovery requires a listed transition. Unknown transitions and future
|
||||
schema versions fail closed.
|
||||
|
||||
Reference in New Issue
Block a user