Author SHA1 Message Date
jb 6af38024fa feat: add catalog weapon throughput baseline 2026-08-07 13:16:12 -04:00
jb 4777a3abd5 feat: refine builder skill chip flows
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 44s
Validate and deploy staging / deploy (push) Successful in 1m14s
2026-08-06 01:00:15 -04:00
jb 46f64d37b6 fix: serialize article autosaves
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 33s
Validate and deploy staging / deploy (push) Successful in 1m11s
2026-08-04 12:45:29 -04:00
jb 7ca6cb8458 Updating mechanics for OS protocols
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 33s
Validate and deploy staging / deploy (push) Successful in 1m9s
2026-08-03 16:12:20 -04:00
jb 57068b61cd feat: expand ISAC content modeling
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 28s
Validate and deploy staging / deploy (push) Successful in 1m3s
2026-08-03 01:23:20 -04:00
109 changed files with 12282 additions and 1629 deletions
+59 -30
View File
@@ -769,15 +769,12 @@ CREATE TABLE IF NOT EXISTS stat_attributes (
weapon_min REAL,
weapon_max REAL,
weapon_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50),
skill_chip_min REAL,
skill_chip_max REAL,
cap_value REAL,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK((gear_min IS NULL AND gear_max IS NULL) OR (gear_min IS NOT NULL AND gear_max IS NOT NULL AND gear_min <= gear_max)),
CHECK((weapon_min IS NULL AND weapon_max IS NULL) OR (weapon_min IS NOT NULL AND weapon_max IS NOT NULL AND weapon_min <= weapon_max)),
CHECK((skill_chip_min IS NULL AND skill_chip_max IS NULL) OR (skill_chip_min IS NOT NULL AND skill_chip_max IS NOT NULL AND skill_chip_min <= skill_chip_max)),
CHECK(cap_value IS NULL OR cap_value >= 0)
);
CREATE UNIQUE INDEX IF NOT EXISTS stat_attributes_name_unit_nocase_idx ON stat_attributes(name COLLATE NOCASE, unit);
@@ -906,6 +903,7 @@ CREATE TABLE IF NOT EXISTS conditional_effect_outcomes (
delivery_mode TEXT NOT NULL DEFAULT 'instant' CHECK(delivery_mode IN ('instant','continuous')),
delivery_duration_seconds REAL CHECK(delivery_duration_seconds IS NULL OR delivery_duration_seconds>0),
targeting_json TEXT,
probability_percent REAL CHECK(probability_percent IS NULL OR (probability_percent>=0 AND probability_percent<=100)),
min_state_value REAL,
max_state_value REAL,
description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1),
@@ -1168,14 +1166,30 @@ CREATE TABLE IF NOT EXISTS skill_chip_two_piece_bonuses (
CHECK(max_value >= min_value)
);
CREATE TABLE IF NOT EXISTS skill_chip_primary_options (
skill_chip_set_id TEXT NOT NULL REFERENCES skill_chip_sets(id) ON DELETE CASCADE,
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
PRIMARY KEY(skill_chip_set_id, stat_attribute_id)
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
);
CREATE TABLE IF NOT EXISTS skill_chip_primary_quality_values (
stat_attribute_id TEXT NOT NULL,
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
min_value REAL NOT NULL,
max_value REAL NOT NULL,
PRIMARY KEY(stat_attribute_id,quality),
FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_primary_options(stat_attribute_id) ON DELETE CASCADE,
CHECK(max_value>=min_value),
CHECK(min_value>=0)
);
CREATE TABLE IF NOT EXISTS skill_chip_secondary_options (
skill_chip_set_id TEXT NOT NULL REFERENCES skill_chip_sets(id) ON DELETE CASCADE,
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
PRIMARY KEY(skill_chip_set_id, stat_attribute_id)
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
);
CREATE TABLE IF NOT EXISTS skill_chip_secondary_quality_values (
stat_attribute_id TEXT NOT NULL,
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
min_value REAL NOT NULL,
max_value REAL NOT NULL,
PRIMARY KEY(stat_attribute_id,quality),
FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_secondary_options(stat_attribute_id) ON DELETE CASCADE,
CHECK(max_value>=min_value),
CHECK(min_value>=0)
);
CREATE TABLE IF NOT EXISTS skill_chip_talents (
id TEXT PRIMARY KEY,
@@ -1232,19 +1246,21 @@ CREATE TABLE IF NOT EXISTS weapon_mod_quality_values (
CHECK(primary_level_20_max>=primary_level_1_max)
);
CREATE TABLE IF NOT EXISTS weapon_mod_secondary_options (
weapon_mod_id TEXT NOT NULL REFERENCES weapon_mods(id) ON DELETE CASCADE,
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
PRIMARY KEY(weapon_mod_id,stat_attribute_id)
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
);
CREATE TABLE IF NOT EXISTS weapon_mod_secondary_quality_values (
weapon_mod_id TEXT NOT NULL,
stat_attribute_id TEXT NOT NULL,
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
min_value REAL NOT NULL,
max_value REAL NOT NULL,
PRIMARY KEY(weapon_mod_id,stat_attribute_id,quality),
FOREIGN KEY(weapon_mod_id,stat_attribute_id) REFERENCES weapon_mod_secondary_options(weapon_mod_id,stat_attribute_id) ON DELETE CASCADE,
CHECK(max_value>=min_value)
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
level_1_min REAL NOT NULL,
level_1_max REAL NOT NULL,
level_20_min REAL NOT NULL,
level_20_max REAL NOT NULL,
PRIMARY KEY(stat_attribute_id,quality),
FOREIGN KEY(stat_attribute_id) REFERENCES weapon_mod_secondary_options(stat_attribute_id) ON DELETE CASCADE,
CHECK(level_1_max>=level_1_min),
CHECK(level_20_max>=level_20_min),
CHECK(level_20_min>=level_1_min),
CHECK(level_20_max>=level_1_max)
);
CREATE TABLE IF NOT EXISTS weapon_mod_exotic_attributes (
id TEXT PRIMARY KEY,
@@ -1292,19 +1308,21 @@ CREATE TABLE IF NOT EXISTS gear_mod_quality_values (
CHECK(primary_level_20_max>=primary_level_1_max)
);
CREATE TABLE IF NOT EXISTS gear_mod_secondary_options (
gear_mod_id TEXT NOT NULL REFERENCES gear_mods(id) ON DELETE CASCADE,
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
PRIMARY KEY(gear_mod_id,stat_attribute_id)
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
);
CREATE TABLE IF NOT EXISTS gear_mod_secondary_quality_values (
gear_mod_id TEXT NOT NULL,
stat_attribute_id TEXT NOT NULL,
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
min_value REAL NOT NULL,
max_value REAL NOT NULL,
PRIMARY KEY(gear_mod_id,stat_attribute_id,quality),
FOREIGN KEY(gear_mod_id,stat_attribute_id) REFERENCES gear_mod_secondary_options(gear_mod_id,stat_attribute_id) ON DELETE CASCADE,
CHECK(max_value>=min_value)
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
level_1_min REAL NOT NULL,
level_1_max REAL NOT NULL,
level_20_min REAL NOT NULL,
level_20_max REAL NOT NULL,
PRIMARY KEY(stat_attribute_id,quality),
FOREIGN KEY(stat_attribute_id) REFERENCES gear_mod_secondary_options(stat_attribute_id) ON DELETE CASCADE,
CHECK(level_1_max>=level_1_min),
CHECK(level_20_max>=level_20_min),
CHECK(level_20_min>=level_1_min),
CHECK(level_20_max>=level_1_max)
);
CREATE TABLE IF NOT EXISTS specialization_progression_config (
id TEXT PRIMARY KEY CHECK(id='default'),
@@ -1404,6 +1422,17 @@ CREATE TABLE IF NOT EXISTS specialization_ability_attacks (
CHECK(charge_policy<>'none' OR charge_count=1)
);
CREATE INDEX IF NOT EXISTS specialization_abilities_specialization_idx ON specialization_abilities(specialization_id,name);
CREATE TABLE IF NOT EXISTS specialization_ability_skill_chip_links (
ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
compatible_ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
PRIMARY KEY(ability_id,compatible_ability_id),
CHECK(ability_id<compatible_ability_id)
);
CREATE INDEX IF NOT EXISTS specialization_ability_skill_chip_links_compatible_idx
ON specialization_ability_skill_chip_links(compatible_ability_id,ability_id);
-- db-migrate.mjs installs insert/update triggers for active same-specialization
-- endpoints and one-pair-per-ability enforcement. Trigger bodies cannot live in
-- this semicolon-delimited schema file.
CREATE TABLE IF NOT EXISTS specialization_ability_effects (
id TEXT PRIMARY KEY,
ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
@@ -1628,7 +1657,7 @@ INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator
INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator,display_order,locked_position)
VALUES('calculation-stage-cycle-1','calculation-revision-1','firing_cycle','Firing cycle','firing_cycle',9,'last');
CREATE TABLE IF NOT EXISTS game_data_icons (
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node')),
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node', 'weapon_mod_slot')),
entity_id TEXT NOT NULL,
storage_key TEXT NOT NULL UNIQUE,
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
+188 -56
View File
@@ -341,17 +341,36 @@ squeezed beside long module headings on mobile.
### Command Grid builder
The production builder uses Command Grid as its loadout workbench. Keep the
entire build visible in a dense matrix while one connected editor configures the
selected cell. The full-width Specialization and Focus cell is the root choice.
Its three supplied abilities appear directly below it, followed by two weapons,
six gear positions, and one full-width OS Protocol.
entire build visible in a compact matrix while one connected editor configures
the selected cell. The editor uses two lanes when its container has enough
width. Equipment identity and base values occupy the first lane; allocations,
rolls, Talents, and upgrades occupy the second. The lanes stack when either
would become too narrow. The full-width Specialization and Focus cell is the
root choice. Its three supplied abilities appear directly below it, followed by
two weapons, six gear positions, and one full-width OS Protocol.
Matrix cells summarize selected Talent names, Attribute names and values, Brand
Sets, or Skill Chips. Do not replace those choices with configuration counts.
Selecting an ability opens its three attached Skill Chip positions. Selecting
equipment opens the searchable real-data catalog and its compatible
configuration controls. Do not display a prototype option that the current
loadout schema cannot save.
Matrix cells show the slot and equipped item at rest. The selected cell reveals
its compact Talent, Attribute, Brand Set, or Skill Chip summary. This
progressive disclosure keeps the matrix scannable without replacing configured
choices with counts. Selecting an ability opens its three attached Skill Chip
positions. Selecting equipment opens the searchable real-data catalog and its
compatible configuration controls. Large Talent pools show the selected Talent
at rest and open their search grid through an explicit Change action. Do not
display a prototype option that the current loadout schema cannot save.
Builder cells use one leading icon track. Specializations, abilities, Skill
Chips, and OS Protocols use registered artwork; weapons and gear use static
category or slot fallbacks when no item artwork exists. Skill Chip slots keep
the icon beside the set name and quality so empty and configured slots share one
scan path. Visible builder labels stay short; full validation text remains
available through the cell's accessible name or title.
Each equipped gear or weapon Talent exposes two adjacent icon choices for Tier I
and Tier II. Both choices reuse the Talent artwork; Tier II applies a restrained
warm recolor so players can distinguish the pair before reading the labels.
Every tile retains `T1` or `T2`, its full tier name, and selected or unavailable
text. Disable Tier II when the canonical Talent has no authored Tier II data.
Weapon equipment tier and Talent tier are independent selections.
Equipment readiness belongs at the point of resolution. Mark an incomplete
matrix cell with a compact red text indicator and status rail, then let the cell
@@ -361,20 +380,19 @@ and Skill DPS, encounter assumptions, activation profiles, rotations, and
simulation as `COMING SOON`; they do not report calculation-profile blockers as
current player errors.
The builder uses three workbench columns on wide screens, moves the live summary
below the matrix and editor at intermediate widths, and stacks all three regions
on narrow screens. Every grid track and editor child must accept content
shrinkage. At 560px and below, stack attribute picker/value pairs and section
headings; keep the command actions in two equal columns. Validation markers use
short visible labels while their full messages remain available to assistive
technology and pointer users.
The builder uses two primary workbench columns on wide screens: the compact
matrix and the two-lane editor. The live summary spans both columns below them.
Narrow screens stack the matrix, editor, and summary. Every grid track and
editor child must accept content shrinkage. At 560px and below, stack Attribute
picker/value pairs and section headings; keep the command actions in two equal
columns. Validation markers use short visible labels while their full messages
remain available to assistive technology and pointer users.
Use `--shd-builder-canvas` for recessed equipment fields. Use
`--shd-builder-selection` with `--shd-builder-selection-border` for the selected
cell. Preserve button semantics, `aria-pressed`, focus-visible outlines, pressed
feedback, and textual readiness states. At narrower desktop widths, keep the
matrix and editor together and move live results below them. On mobile, stack
the matrix, editor, and live results in that order.
feedback, and textual readiness states. On mobile, stack the matrix, editor, and
live results in that order.
The command header contains the required build name, local and account save
actions, account visibility, and optional sharing notes. Sharing notes include a
@@ -1572,13 +1590,42 @@ an icon in every option.
### Skill Chip data console
Skill Chip editing uses the dense ISAC staged-definition pattern: identity and
icon, 2-piece numeric bonus, mutually exclusive primary and secondary roll
pools, then 3-piece talent. Superior blue and High-end yellow identify the
supported qualities; reserve orange for actions and the current interactive
state. A four-step segmented preview shows the linear zero-to-three High-end
interpolation without duplicate values. At narrow widths, stack roll-pool and
talent columns, use a two-by-two preview grid, and retain 44-pixel targets.
Place two shared configuration cards above the Skill Chip definition registry.
The `SHARED PRIMARY PROFILE` card contains the fixed Skill Duration, Skill
Radius, Skill Cooldown Recovery, and Skill Intensity rows, with direct minimum
and maximum fields for Standard, Specialized, Superior, and High-end. Its
summary shows the configured count for each quality and the `NOT YET DEFINED`
calculation state. The `SHARED SECONDARY POOL` card contains repeatable
searchable Attribute rows with direct minimum and maximum fields for
Specialized, Superior, and High-end. Its summary shows each quality's fixed
capacity and the shared option count. Each card opens its own editor and closes
that editor after a successful save. Validation keeps the affected editor open
with its values intact. Successful saves append
`game_data/skill_chip_primary_profile.updated` or
`game_data/skill_chip_secondary_pool.updated`.
State the fixed secondary capacities in the pool card and editor: Standard zero,
Specialized one, Superior two, and High-end two. Skill Chip fields never show an
item-level control or level-based preview. Quality labels use the global green,
blue, purple, and yellow treatments; orange remains reserved for actions and
selection. On narrow screens, profile summaries stack, endpoint matrices scroll
inside their cards, and actions retain 44-pixel targets.
Definition editors follow the dense staged pattern: ability assignment, identity
and icon, one per-set 2-piece bonus, then the optional 3-piece Talent. Place the
Talent assignment control before one disclosure that contains the entire Talent
workspace: identity, description, quality-mix preview, and
`MechanicProgramEditor`. The disclosure summary shows the configured Talent name
or `TALENT WORKSPACE`, the quality-scaled outcome count, and an explicit
`EXPAND` or `COLLAPSE` action. Existing Talents start collapsed when an operator
opens a definition. Assigning a Talent or receiving a Talent validation error
opens the workspace. Native disclosure semantics expose the expanded state, keep
draft state mounted while collapsed, and provide a 44-pixel mobile target.
Every equipped chip quality counts toward the two-piece threshold. The set-bonus
preview uses four steps for zero through three equipped High-end chips and the
formula `minimum + (maximum − minimum) × h / 3`; only High-end chips increase
`h`. Do not duplicate calculated values as editable fields.
### ISAC record identity controls
@@ -1808,6 +1855,15 @@ same `weaponBaseRange` curve as the builder; never label the configured base
endpoints as level-50 values. Weapon Talent 1 and Talent 2 pools use separate
clickable icon rails and the standard Talent quick view.
Show each weapon's named Mod slots and slot pictograms between its performance
facts and Talent pools. Native T0 weapons display the complete T1/T2 slot set.
Mark the slot that is absent at T0 with the blue information state and an
explicit `T1+` label so the unlock does not rely on color alone. Other slots use
the neutral telemetry treatment. If ISAC has no slot data, show
`MOD SLOT DATA NOT CONFIGURED`. Do not show the weapon-category bonus count as
`BASE BONUSES`; that identical count does not help players compare weapon
definitions.
Record cards may end with contextual actions that connect related public
registries. Actions use the compact orange tactical-button treatment and encode
their destination filter in the URL. Brand cards provide a “find Gear” action
@@ -1970,18 +2026,26 @@ mode with weapon icons and retained accessible names. Show configured versus
required slot counts in text. Disabled tiers remain visibly unavailable and must
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 +2057,20 @@ state panel visible so the change in data semantics is unmistakable.
### Gear Mod registry
Gear Mods reuse the compact quality matrix, level preview, and pooled
secondary-attribute rows established by Weapon Mods. Do not render exotic,
Gear Mods reuse the compact Level 1 and Level 20 primary-range matrix and level
preview established by regular Weapon Mods. Do not render exotic,
weapon-category, slot-type, or Mod Effect controls in this simpler editor.
Present the five primary options as an always-visible informational band because
the actual primary is selected on the build instance, not persisted on the
canonical definition.
the build instance selects the primary; the canonical definition does not store
that selection.
Configure the Gear Mod family’s global secondary Attribute pool above the
registry, separate from individual Gear Mod editors. Use the same three-quality
endpoint rows, linear interpolation, and distinct-selection rule as the Weapon
Mod pool. Capacity is zero for Standard, one for Specialized, and two each for
Superior and High-end. Individual Gear Mod edits must not repeat or submit this
pool. A successful pool save creates the dedicated
`game_data/gear_mod_secondary_pool.updated` audit event.
Use explicit textual tier capacity wherever Gear tier is summarized:
`1 MOD SLOT` for T0 and `2 MOD SLOTS` for T1/T2. On narrow screens the identity
@@ -2229,6 +2301,13 @@ active abilities, each ability has exactly three active Skill Chip definitions,
tree rows are contiguous, at least one paid node exists, and the tree uses the
configured available point total.
The ability editor may pair the open ability with one active ability from the
same specialization for Skill Chip sharing. Present this as an explicit optional
relationship, exclude the current ability, and disable abilities that already
belong to another pair. Do not infer compatibility from names or Focus
membership. Saving either side updates the shared pair and includes it in the
ability audit snapshot. Archiving either ability clears the pair.
Compose focus identity from uploaded specialization artwork and the canonical
Firepower, Toughness, or Engineering icon. Keep the specialization art primary
and place the attribute mark in a smaller corner badge. Mirror ability slots 1
@@ -2325,6 +2404,16 @@ applied to the stored value. Restoration outcomes also expose an `INSTANT` or
field and describes the configured value as the total delivered across that
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
@@ -2506,15 +2595,39 @@ anchor scrolling and sticky-state transitions.
#### Builder calculation modes
The current Builder and shared Build pages do not expose calculation modes or
combat assumptions. They show deterministic equipped-item totals and reserve
conditional Talent outcomes, weapon and Skill DPS, encounter assumptions,
activation profiles, rotations, and simulation behind explicit `COMING SOON`
panels.
combat assumptions. The Builder summary rail additionally contains one local
experimental catalog baseline panel; shared Build pages do not render it. Keep
the panel in the summary rail only, with two independent cards labeled `PRIMARY`
and `SECONDARY`.
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:
Use the exact eyebrow `WEAPON OUTPUT / CATALOG BASELINE` and show the
`EXPERIMENTAL` state. Each card displays the catalog weapon ID, selected damage,
catalog RPM, magazine, reload, rated body-hit throughput, and magazine-cycle
body-hit throughput. Include a visible formula and exclusions block with these
exact formulas:
- `selected damage × (RPM ÷ 60)` for rated body-hit throughput;
- `(magazine × selected damage) ÷ ((magazine ÷ (RPM ÷ 60)) + reload seconds)`
for magazine-cycle body-hit throughput.
The panel is evidence-only. It excludes inferred or substituted values, combat
profiles, effects, Talents, Attribute totals, calculation profiles, accuracy,
critical hits, headshots, enemy mitigation, target count, encounter uptime,
rotations, and simulation. It must not present these values as actual, expected,
or encounter DPS. When a required source value is unavailable or invalid, show
`NOT AVAILABLE` and list the missing source fields under the metric; never
display zero as a placeholder.
The panel does not alter the planned calculation modes or full combat panels.
Keep conditional Talent outcomes, weapon and Skill DPS, encounter assumptions,
activation profiles, rotations, and simulation behind explicit `COMING SOON`
panels. Calculation modes remain planned and use these visible labels:
Loadout schema version 6 retains `maximum`, `custom`, conditional state,
encounter-assumption, and per-ability activation fields. Version 4 is obsolete
prototype data rather than a compatibility format. The current player UI ignores
the simulation fields. When player simulation ships, one required two-option
`SegmentedChoice` will use these visible labels:
- `MAXIMUM POTENTIAL` calculates a configured ceiling. It applies conditional
effects at peak state and treats selected abilities as firing on cooldown
@@ -2565,8 +2678,17 @@ The builder presents Specialization and Focus as the first loadout decision. The
Focus sets the primary Attribute, progression tree, and three ordered abilities.
Its identity combines Specialization artwork and the primary Attribute mark.
Each ability panel contains three fixed Skill Chip positions. A chip picker
shows definitions assigned to that ability and excludes definitions assigned
elsewhere.
shows the ability's three definitions. When ISAC explicitly pairs that ability
with one compatible ability, the picker also shows the partner's three
definitions and labels shared entries. The relationship is bidirectional and
does not apply to other abilities in the same specialization. Players still
equip three chips in each ability panel. Skill Chips have no item-level control.
The editor uses the shared quality profiles for direct rolls. It labels the four
ability-scoped primary values `NOT YET DEFINED` and excludes them from current
Agent and combat totals. Shared-chip primary values and 3-piece Talent outcomes
target the ability represented by the panel. Secondary capacity is Standard
zero, Specialized one, Superior two, and High-end two; each equipped chip uses
distinct selections from the shared pool.
The focus tree groups nodes by row and uses segmented rank controls. Disabled
ranks remain visible with their lock state. The builder permits a row after the
@@ -2581,11 +2703,11 @@ enabled ability. Future Skill result cards will show activation count,
single-target DPS, total-target DPS, and a combined summary. They will state
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 +2772,13 @@ asset and states that the change is permanent. For assigned icons, the
confirmation names the owning entity type. ISAC clears that source icon
assignment before it removes the shared file. General library uploads retain the
`ICON` or `IMAGE` state.
In the Weapon Mod registry, operators manage shared artwork for all eight
code-defined slots. The compact panel labels each slot `ICON REGISTERED` or
`ICON REQUIRED`. Selecting a slot opens the standard icon upload control. Keep
the slot name visible in ISAC after an operator uploads artwork.
On public weapon cards, a configured slot chip shows only its uploaded icon.
Keep the slot name in screen-reader and hover text. Retain the visible `T1+`
marker and blue information treatment for slots unlocked at Tier 1. When artwork
is missing, show the slot name instead of an invented glyph.
+41
View File
@@ -0,0 +1,41 @@
# Weapon baseline prototype
## Scope
`calculateWeaponBaseline` is a pure, client-safe Builder helper. It records
weapon ID, name, and label, then reports three evidence-only metrics:
- `selectedDamage`: the supplied positive finite damage value.
- `ratedBodyHitThroughput`: `selectedDamage × rpm / 60`.
- `magazineCycleBodyHitThroughput`:
`(selectedDamage × magazine) / (magazine / (rpm / 60) + reloadSeconds)`.
Unknown, absent, non-positive, non-finite, or invalid source values make only
the metrics that depend on them unavailable. The helper preserves decimal inputs
and does not substitute values.
These are not actual, expected, or encounter DPS. They contain no hit
probability, critical or headshot outcomes, target state, or encounter timeline.
The rated metric is nominal continuous body-hit throughput; the magazine-cycle
metric includes the supplied reload interval.
## Excluded inputs
This helper does not read combat profiles, effects, talents, Attribute totals,
calculation profiles, encounter assumptions, or any other build inputs. It does
not call the simulation or combat engines.
`selectedDamage` is already the equipped weapon damage used at this boundary. It
is consumed exactly once; no additional level scaling or damage scaling is
applied.
## Missing data for a DPS model
A valid DPS model still needs the authoritative damage calculation and its
modifiers, hit and critical/headshot assumptions, firing and reload behavior,
target and encounter assumptions, and the timing and contribution of talents,
effects, Attributes, skills, and weapon swaps. Those data are outside this
baseline.
This is Builder-only, local prototype work. It does not define the player DPS
simulation contract or change the existing calculation architecture.
+38 -18
View File
@@ -26,8 +26,8 @@ Use these event boundaries:
- **Healing:** Count each healing event. A heal-over-time effect counts each
tick as a separate event.
- **Extra Health gained:** Count every Extra Health grant, including a grant
received while the Agent already has Extra Health. Use
`extra_health_granted` for the grant action.
received while the Agent already has Extra Health. Use `extra_health_granted`
for the grant action.
- **Extra Health state changed:** `extra_health_changed` is Agent-scoped. Emit
it when the Agent receives a grant, when the remaining amount changes, and
when it is depleted to zero. Every event requires the boolean
@@ -35,8 +35,8 @@ Use these event boundaries:
`values.currentExtraHealth`, which must agree with that status. This state
event is distinct from the `extra_health_granted` grant action.
- **Reloading:** Trigger on reload completion when the weapon had at least one
round to load. A canceled reload does not count. A weapon that loads one
round at a time produces one completed-reload event for each round.
round to load. A canceled reload does not count. A weapon that loads one round
at a time produces one completed-reload event for each round.
- **Taking damage:** Require Health loss. A hit absorbed by Extra Health does
not count as taking damage.
@@ -59,8 +59,8 @@ Inspiration and Crossfire use a binary deployed-Skill state:
- The benefit remains active while the Agent has at least one persistent
deployed Skill object.
- Destruction or timed expiration removes that object from the active count.
The benefit ends when no qualifying objects remain.
- Destruction or timed expiration removes that object from the active count. The
benefit ends when no qualifying objects remain.
- Instant-use Skills do not qualify.
- Multiple deployed Skill objects do not multiply the benefit.
@@ -75,8 +75,8 @@ meters:
- Moving within 30 meters of an ally who is already downed qualifies.
- Each qualifying presence refreshes the ten-second duration.
- Leaving the radius or reviving the ally does not cancel the current
ten-second duration.
- Leaving the radius or reviving the ally does not cancel the current ten-second
duration.
- The wearer does not count as an ally.
## Emergency Retreat
@@ -93,9 +93,9 @@ qualifies when the weapon had at least one round to load. This includes each
round inserted by a weapon that reloads one round at a time. A canceled reload
does not qualify.
Resolve the Extra Health amount from the Agent's displayed Firepower when
Second Wind activates. Include all equipped-item and active-buff contributions
in that Firepower value:
Resolve the Extra Health amount from the Agent's displayed Firepower when Second
Wind activates. Include all equipped-item and active-buff contributions in that
Firepower value:
- Tier I grants Extra Health equal to 600% of displayed Firepower.
- Tier II grants Extra Health equal to 840% of displayed Firepower.
@@ -103,19 +103,39 @@ in that Firepower value:
Second Wind adds its Extra Health to Extra Health from other sources. Its
five-second duration and five-second cooldown start when it activates. Expiry
removes only the Second Wind contribution. At the five-second boundary, the
prior grant can expire as a qualifying reload completion grants a new amount.
If Second Wind supplies the Agent's only Extra Health, the old amount expires
and the new amount begins at that boundary.
prior grant can expire as a qualifying reload completion grants a new amount. If
Second Wind supplies the Agent's only Extra Health, the old amount expires and
the new amount begins at that boundary.
## Nothing to Lose
Nothing to Lose uses a forward-looking deployment window:
- Each Health-damage instance makes its own 50% activation roll. Damage
absorbed by Extra Health does not roll.
- Each Health-damage instance makes its own 50% activation roll. Damage absorbed
by Extra Health does not roll.
- A successful activation applies to every Skill deployed during the five
seconds after that damage instance. Several Skills can qualify during the
same window.
seconds after that damage instance. Several Skills can qualify during the same
window.
- Calculate each reduction from the Skill's total cooldown after equipment
modifiers.
- A Skill can receive no more than three reductions during one cooldown cycle.
## OS Protocol Talents
Happy Survival restores Health once per second while it has a qualifying nearby
enemy count. Each restoration resolves 0.5% of maximum Health per enemy within
10 meters, capped at 2.5% before OS Protocol upgrade scaling.
Explosive Charge requires three seconds of uninterrupted firing. Completion
opens an eight-second window to deploy the next Skill. The window ends when the
Agent deploys that Skill. Only the enhanced Skill's first damaging hit creates
the five-meter explosion. The shared Talent cooldown rule starts its five-second
cooldown when that explosion activates.
Heavy Armor and Pummeling Shield replace and refresh their own active Extra
Health grant when the same Talent activates again. Extra Health grants from
different sources remain separate.
Pummeling Shield counts confirmed weapon hits while the Agent possesses Extra
Health. Losing all Extra Health resets the hit count. The full-Health condition
checks Health immediately before the incoming hit resolves.
+180 -68
View File
@@ -25,12 +25,16 @@ The team builds the project in vertical slices.
Server code checks authorization for each mutation. UI visibility has no effect
on server authorization.
Builder and shared Build pages currently expose only deterministic totals from
the equipped items, selected rolls, fixed bonuses, inherited profiles, augments,
and active set bonuses. Conditional Talent outcomes, weapon and Skill DPS,
encounter assumptions, activation profiles, rotations, and simulation remain
planned. Explicit `COMING SOON` panels identify those player-facing
capabilities.
Builder and shared Build pages expose deterministic totals from the equipped
items, selected rolls, fixed bonuses, inherited profiles, augments, and active
set bonuses. Builder additionally shows an experimental catalog baseline for
each selected weapon: body-hit throughput from selected weapon damage and
catalog RPM, magazine, and reload values only. It is not expected or encounter
DPS, does not infer or substitute missing values, reports blocked metrics with
their source fields, and does not change shared Build behavior. Conditional
Talent outcomes, full weapon and Skill DPS, encounter assumptions, activation
profiles, rotations, and simulation remain planned. Explicit `COMING SOON`
panels identify those player-facing capabilities.
Weapon and gear range calculations must preserve decimal endpoints and follow
the tested progression rules in
@@ -431,11 +435,13 @@ reference.
# OS Protocol progression
OS Protocols use dedicated normalized tables because their talent progression
has six calculated states (base plus five upgrades) rather than the shared
two-tier talent model. Each protocol owns one `os_protocol_primary_effects`
record and one `os_protocol_talents` record. Each protocol Talent owns its
mechanic program through the shared conditional-effect tables.
OS Protocols use dedicated normalized tables because an assigned Talent has six
calculated states: its base value plus five upgrades. Each protocol owns one
`os_protocol_primary_effects` record and may own one `os_protocol_talents`
record. The optional Talent owns its mechanic program through the shared
conditional-effect tables. ISAC keeps the Talent workspace collapsed until an
operator assigns or opens it, and removing an existing Talent requires explicit
confirmation.
Dedicated profile tables normalize the three secondary attributes. One
`os_protocol_secondary_profiles` record exists for each configured quality +
@@ -460,6 +466,15 @@ compatible base weapon categories through `weapon_mod_compatible_categories`;
eligibility requires both a category match and the slot to exist on the equipped
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 +484,21 @@ values in `weapon_mod_exotic_attributes`; intermediate values use linear
interpolation.
All regular Weapon Mods permit Firepower, Toughness, or Engineering as a
builder-time primary choice. `weapon_mod_quality_values` stores the shared
level-1 and level-20 primary roll endpoints for each quality; primary values use
linear interpolation from levels 1–20.
builder-time primary choice. `weapon_mod_quality_values` stores each
definition's level-1 and level-20 primary roll endpoints for each quality;
primary values use linear interpolation from levels 1–20.
`weapon_mod_secondary_options` is the shared pool from which a build selects two
distinct secondary attributes. Every option owns a minimum and maximum at each
quality through `weapon_mod_secondary_quality_values`; secondary availability is
not tied to a numbered slot in canonical data. Mod Effects are triggerless, hold
one or two attribute references, and store fixed per-quality values in
`weapon_mod_effect_quality_values`.
`weapon_mod_secondary_options` stores one global secondary Attribute pool for
all regular Weapon Mods. Each Attribute has Level 1 and Level 20 minimum and
maximum ranges for Specialized, Superior, and High-end quality through
`weapon_mod_secondary_quality_values`; values at intermediate levels use linear
interpolation. Secondary capacity is fixed by quality: Standard has zero,
Specialized has one, Superior has two, and High-end has two. A build selects
distinct Attributes from the pool up to that capacity. Individual Weapon Mod
records do not own secondary options or ranges. Pool updates append a
`game_data/weapon_mod_secondary_pool.updated` audit event. Mod Effects remain
triggerless, hold one or two Attribute references, and store fixed per-quality
values in `weapon_mod_effect_quality_values`.
### Gear Mods and tier slot capacity
@@ -488,42 +508,86 @@ Specialized, Superior, and High-end quality at Levels 1 and 20. Values between
those endpoints use linear interpolation. A build instance selects one primary
attribute from Firepower, Toughness, Engineering, Health, or Armor.
The two secondary slots draw distinct attributes from one shared option pool.
Each available secondary attribute stores its own minimum and maximum for all
four qualities. Gear tier determines capacity without per-item configuration: T0
gear has one Gear Mod slot; T1 and T2 gear have two.
One global Gear Mod secondary Attribute pool serves every Gear Mod definition.
Each Attribute has Level 1 and Level 20 minimum and maximum ranges for
Specialized, Superior, and High-end quality through
`gear_mod_secondary_quality_values`; values at intermediate levels use linear
interpolation. Secondary capacity is fixed by quality: Standard has zero,
Specialized has one, Superior has two, and High-end has two. A build selects
distinct Attributes from the pool up to that capacity. Individual Gear Mod
records do not own secondary options or ranges. Gear tier still determines mod
slot capacity without per-item configuration: T0 gear has one Gear Mod slot; T1
and T2 gear have two.
The `gear_mods`, `gear_mod_quality_values`, `gear_mod_secondary_options`, and
`gear_mod_secondary_quality_values` tables store canonical data. Successful
mutations use `gear_mod.created`, `gear_mod.updated`, `gear_mod.archived`, and
`gear_mod.unarchived` audit actions. Contributor submissions use the `gearmods`
proposal module and remain non-canonical until approved by Oversight.
The `gear_mods` and `gear_mod_quality_values` tables store individual definition
data. `gear_mod_secondary_options` and `gear_mod_secondary_quality_values` store
the family-wide pool. Pool updates append a
`game_data/gear_mod_secondary_pool.updated` audit event. Individual definition
mutations retain the `gear_mod.created`, `gear_mod.updated`,
`gear_mod.archived`, and `gear_mod.unarchived` audit actions. Contributor
submissions use the `gearmods` proposal module and remain non-canonical until
approved by Oversight.
# Skill Chip quality scaling
Skill Chip sets support Superior and High-end chip instances. Each set owns one
2-piece bonus, distinct primary and secondary stat-option pools, and one 3-piece
Talent with a typed conditional rule. Primary and secondary options reference
active Attributes that define Skill Chip minimum and maximum roll values. A
configured chip rolls one primary and two distinct secondary stats from those
pools.
Each Skill Chip definition belongs to one specialization ability. Each set owns
one 2-piece bonus and may own one 3-piece Talent with a typed conditional rule.
ISAC keeps the complete Talent workspace, including its description and mechanic
program, inside one collapsible optional section.
Each active Skill Chip definition belongs to one active specialization ability
through `skill_chip_sets.specialization_ability_id`. An ability has capacity for
three active definitions. The save and restore actions enforce that capacity,
and the schema requires the foreign key for each row. A specialization focus is
All definitions use one fixed primary profile. The profile contains Skill
Duration, Skill Radius, Skill Cooldown Recovery, and Skill Intensity, with
direct minimum and maximum values for Standard, Specialized, Superior, and
High-end. Primary values apply to the assigned ability rather than the Agent.
Builder and shared Build pages label them `NOT YET DEFINED` and exclude them
from current Agent and combat totals.
All definitions also use one shared secondary Attribute pool. Each pool entry
stores direct minimum and maximum values for Specialized, Superior, and
High-end. Standard chips have no secondary slot, Specialized chips have one, and
Superior and High-end chips have two. A configured chip selects distinct
secondary Attributes up to its quality's capacity. Skill Chips have no item
level, so neither profile uses level interpolation.
Active definitions reference an active specialization ability through
`skill_chip_sets.specialization_ability_id`. An ability has capacity for three
active definitions. The save and restore actions enforce that capacity, and the
schema requires the foreign key for each row. A specialization focus is
builder-ready only when each of its three assigned abilities has all three
active Skill Chip definitions. Skill Chip mutations refresh readiness for
focuses that reference both the previous and resulting ability.
Both set-bonus progression systems use the number of equipped High-end chips as
the interpolation step. For `h` High-end chips, where `h` is zero through three,
a configured minimum and maximum resolve as
`minimum + (maximum − minimum) × h / 3`. Zero High-end chips therefore produce
the minimum, while three produce the maximum. The editor stores endpoints and
previews all four states, preventing duplicated tier data from diverging. The
`game_data` audit stream captures Skill Chip mutations, owned relationships, and
safe icon metadata.
`specialization_ability_skill_chip_links` may pair two active abilities from the
same specialization. Operators create each pair explicitly; the application does
not infer compatibility from ability names, focus membership, or shared
specialization. The pair is bidirectional, and an ability may belong to only one
pair. This permits compatible abilities such as two Tactical Link variants to
share definitions without making unrelated pairs such as two Smart Cover
variants compatible. Archiving either ability removes the pair; restoring an
ability does not recreate it.
A linked ability can equip its own three definitions or its partner's three
definitions, so each of its three Builder positions offers at most six choices.
The loadout still equips three Skill Chips per ability and nine per focus.
Ability-scoped primary values and 3-piece Talent outcomes bind to the ability
that owns the equipped position, not the definition's original owner. Two-piece
and 3-piece thresholds remain local to one equipped ability and never combine
matching definitions across ability panels. ISAC audit snapshots and game-data
transfers include the compatibility pair.
Every equipped quality counts toward the two-piece threshold. After the loadout
meets that threshold, only equipped High-end chips increase the interpolation
step. For `h` High-end chips, where `h` is zero through three, a configured
minimum and maximum resolve as `minimum + (maximum − minimum) × h / 3`. Zero
High-end chips resolve to the minimum, while three resolve to the maximum. The
editor stores endpoints and previews all four states.
ISAC presents the fixed primary profile and secondary pool as two shared
registry/editor cards above the definition registry. Successful profile saves
append `game_data/skill_chip_primary_profile.updated` and
`game_data/skill_chip_secondary_pool.updated`, respectively. Definition
mutations continue to capture their owned bonus, optional Talent, ability
relationship, and safe icon metadata in the `game_data` audit stream.
# Mechanic programs and loadout state
@@ -542,6 +606,9 @@ state, with sprinting taking precedence. Enabled Skill activation profiles emit
the deployed ability. An `on_event` Skill modifier applies to that activation
until the ability's next deployment or the encounter ends.
`skill_deployed.values.abilityId` and `skill_hit.values.abilityId` identify the
same ability for mechanics that bind an effect to the next deployed Skill.
Each source supplies a fixed value-variant contract:
- Standard Talents require Tier I and Tier II values. Exotic Talents require
@@ -577,7 +644,23 @@ state, source, movement, and nearby counts travel as typed event values. The
event registry publishes authoritative field metadata where branches need a
bounded contract. For those events, the mechanic editor derives its event-field
controls from that metadata. For example, `weapon_hit.values.headshot` is a
boolean that distinguishes headshots from body shots.
boolean that distinguishes headshots from body shots. `enemy_killed` and
`assist` may carry `values.assignedAbilityActive`; a Skill Chip Talent uses that
boolean to restrict a kill or assist trigger to its assigned ability's active
period. `skill_deployed` and `signature_ability_deployed` may carry
`values.assignedAbilityDurationSeconds` to start assigned-ability state. The
runtime expires that state before it evaluates another event at the same
timestamp.
`weapon_hit.values.aiming` records whether the Agent was aiming down sights when
the hit resolved. A producer omits the field when it lacks authoritative aiming
state; the runtime does not replace missing evidence with a scenario assumption.
Predicates compare this field explicitly with `true` or `false`; negation and
inequality cannot turn missing evidence into a match. A `weapon_hit` or
`skill_hit` event may include an event-target-centered area sample. The bounded
sample lists up to 256 valid ally and enemy entity IDs with their distances from
the hit target and includes that target as an enemy at zero meters. Duplicate
IDs, negative distances, or a missing center target invalidate the evaluation.
`extra_health_changed` is Agent-scoped. Normalized timeline producers emit it
when the Agent receives an Extra Health grant, when the remaining amount
@@ -612,7 +695,10 @@ The program graph uses three child tables:
durations, intervals, or derived values. Mutations can set, add, consume,
clear, derive, or transfer state. A mutation may bind a target-scoped pool to
the current event target, event actor, or another typed entity selector. Each
target owns an independent value and expiry timer.
target owns an independent value and expiry timer. A clause may require active
assigned-ability state and carry `procChancePercent`. The runtime evaluates
that chance after predicates and cooldown checks, before reducers, mutations,
and outcomes.
- `conditional_effect_outcomes` applies Attribute modifiers, direct damage,
restoration, resource costs, or markers. Each outcome names its target scope,
value basis, optional resource or Attribute, combat timing, optional state
@@ -621,15 +707,37 @@ The program graph uses three child tables:
required by the owning source. The common value models are static values, live
Attribute values, percentage-normalized Attribute values, event-field values,
reducer output, source-defined scenario values, and OS Protocol upgrade
scaling. Restoration outcomes may deliver their total immediately or
scaling. An outcome may carry `probabilityPercent`; each eligible outcome
rolls separately. Restoration outcomes may deliver their total immediately or
continuously over an explicit duration.
Target-scoped direct damage may use `event_target_area` targeting for an
explosion around its event target. This targeting selects valid enemies and
stores a positive radius in meters. It is valid only for `on_event` or
`after_event` outcomes. The legacy `within_radius` shape remains descriptive
targeting metadata and does not select runtime entities.
The generic runtime in `src/lib/calculations/mechanic.ts` processes combat
events in timestamp order. Predicates decide whether an event enters a clause.
Reducers convert event streams into activations or derived values. The runtime
applies state mutations, enforces pool bounds and expiry, emits outcomes in
phase order, and records telemetry for each activation.
When `event_target_area` damage activates, the runtime requires the triggering
event's authoritative area sample. It includes valid enemies whose distance is
less than or equal to the configured radius, sorts them by entity ID, and emits
one equal damage outcome for each entity. The outcome chance resolves once
before fan-out. A missing sample invalidates the evaluation instead of producing
single-target damage or treating the area as empty.
Custom evaluation uses a Mulberry32 stream seeded by `randomSeed`; an omitted
seed resolves from the stable mechanic program ID. The returned evaluation
reports the seed. Maximum mode resolves every nonzero clause or outcome chance
as successful. `extend_duration` changes only an active assigned ability's
remaining duration. `start_cooldown_on_max` writes an expiring Agent cooldown
pool only when an earlier mutation in the same clause transitions a capped stack
pool to its maximum.
Each write to an expiring pool refreshes its timer. When a pool expires,
`on_exit` outcomes evaluate stack thresholds and per-stack expressions against
the final value before reset. The runtime records the bound target on emitted
@@ -647,11 +755,13 @@ substitute zero.
The read-only `combat.currentHealthPercent` reference divides resolved current
Health by resolved maximum Health and returns a value from 0 through 100. The
runtime derives it when a predicate is evaluated instead of storing a second
Health value that could become stale. Current timing observes deterministic
resource changes from earlier branches at the same event. Health-band mechanics
use strict upper bounds and inclusive lower bounds, so bands do not overlap and
an exact threshold enters the next weaker band. Maximum-potential evaluation
applies the same predicates before emitting passive outcomes.
Health value that could become stale. Pre-event timing reads the combat snapshot
before mechanic branches process the event. Current timing observes
deterministic resource changes from earlier branches at the same event.
Health-band mechanics use strict upper bounds and inclusive lower bounds, so
bands do not overlap and an exact threshold enters the next weaker band.
Maximum-potential evaluation applies the same predicates before emitting passive
outcomes.
Continuous restoration resolves the outcome's total when the branch activates,
then delivers that total at a constant rate over the configured duration.
@@ -793,17 +903,18 @@ applicable, default value, and best-case value. ISAC retains these definitions
for continued mechanic authoring. Current Builder and shared Build pages do not
render or evaluate them. Their calculation panels display `COMING SOON` instead.
Loadout schema version 4 stores one focus, selected tree ranks, nine Skill
Chips, a unified conditional-effect state map, encounter assumptions,
per-ability activation controls, and `calculationMode`. Runtime readers accept
the exact version 4 structure. 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.
Loadout schema version 6 stores one focus, selected tree ranks, nine Skill
Chips, independent T1/T2 choices for each equipped gear and weapon Talent, a
unified conditional-effect state map, encounter assumptions, per-ability
activation controls, and `calculationMode`. Runtime readers accept the exact
version 6 structure. A backed-up migration translated version 5 equipment
Talents from their previous equipment-tier-derived behavior. The schema retains
its simulation fields, but the current player UI ignores them. Version 4 remains
obsolete prototype data.
## Planned player calculation modes
Loadout v4 defines `maximum` and `custom` modes for the planned simulation UI:
Loadout v6 defines `maximum` and `custom` modes for the planned simulation UI:
- Maximum mode supplies each scenario input's best-case value, selects peak
conditional-effect state, and runs selected abilities on cooldown against
@@ -925,11 +1036,12 @@ does not scale.
# Game-data icon storage
Talents, brand sets, OS Protocols, and Skill Chips reference uploaded artwork
through the polymorphic `game_data_icons` metadata table. The shared server
pipeline accepts transparent SVG, PNG, WebP, and AVIF sources, rejects active
SVG content and external references, and uses Sharp’s native worker pool to
rasterize and normalize each accepted icon to a transparent 256 × 256 WebP.
Talents, brand sets, OS Protocols, Skill Chips, Specializations, and shared
Weapon Mod slots reference uploaded artwork through the polymorphic
`game_data_icons` metadata table. The shared server pipeline accepts transparent
SVG, PNG, WebP, and AVIF sources, rejects active SVG content and external
references, and uses Sharp’s native worker pool to rasterize and normalize each
accepted icon to a transparent 256 × 256 WebP.
Normalized files live beneath the ignored `data/game-icons/` directory. The
validated `/icons/game-data/[type]/[id]` endpoint serves them with immutable
+555 -7
View File
@@ -45,7 +45,8 @@ const isRoll = (value) =>
isFiniteNumber(value.value);
const isRollPair = (value) =>
Array.isArray(value) && value.length === 2 && value.every(isRoll);
const isGearSelection = (value) =>
const isTalentTier = (value) => value === 1 || value === 2;
const isLegacyGearSelection = (value) =>
hasExactKeys(value, [
'itemId',
'level',
@@ -68,7 +69,7 @@ const isGearSelection = (value) =>
isRollPair(value.rolls) &&
Array.isArray(value.augmentSlots) &&
value.augmentSlots.every(isFiniteNumber);
const isWeaponSelection = (value) =>
const isLegacyWeaponSelection = (value) =>
hasExactKeys(value, [
'itemId',
'level',
@@ -88,12 +89,68 @@ const isWeaponSelection = (value) =>
(key) => isFiniteNumber(value[key]),
) &&
isRollPair(value.rolls);
const isGearSelection = (value) =>
hasExactKeys(value, [
'itemId',
'level',
'brandId',
'talentId',
'talentTier',
'health',
'armor',
'firepower',
'toughness',
'engineering',
'rolls',
'augmentSlots',
]) &&
['itemId', 'brandId', 'talentId'].every(
(key) => typeof value[key] === 'string',
) &&
isTalentTier(value.talentTier) &&
['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every(
(key) => isFiniteNumber(value[key]),
) &&
isRollPair(value.rolls) &&
Array.isArray(value.augmentSlots) &&
value.augmentSlots.every(isFiniteNumber);
const isWeaponSelection = (value) =>
hasExactKeys(value, [
'itemId',
'level',
'tier',
'damage',
'firepower',
'toughness',
'engineering',
'talent1Id',
'talent1Tier',
'talent2Id',
'talent2Tier',
'exoticTalentTier',
'rolls',
]) &&
['itemId', 'talent1Id', 'talent2Id'].every(
(key) => typeof value[key] === 'string',
) &&
isTalentTier(value.talent1Tier) &&
isTalentTier(value.talent2Tier) &&
isTalentTier(value.exoticTalentTier) &&
['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every(
(key) => isFiniteNumber(value[key]),
) &&
isRollPair(value.rolls);
const isChipSelection = (value) =>
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
typeof value.setId === 'string' &&
(value.quality === 'Superior' || value.quality === 'High-end') &&
['Standard', 'Specialized', 'Superior', 'High-end'].includes(value.quality) &&
isRoll(value.primary) &&
isRollPair(value.secondary);
Array.isArray(value.secondary) &&
value.secondary.length ===
({ Standard: 0, Specialized: 1, Superior: 2, 'High-end': 2 }[
value.quality
] ?? -1) &&
value.secondary.every(isRoll);
const isProtocolSelection = (value) =>
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) &&
typeof value.itemId === 'string' &&
@@ -185,7 +242,7 @@ const isCurrentLoadoutSnapshot = (raw) => {
'skillActivations',
'specialization',
]) &&
value.version === 4 &&
value.version === 6 &&
typeof value.buildName === 'string' &&
['maximum', 'custom'].includes(value.calculationMode) &&
hasExactKeys(value.gear, gearSlots) &&
@@ -207,6 +264,49 @@ const isCurrentLoadoutSnapshot = (raw) => {
);
};
const isLegacyLoadoutSnapshot = (raw) => {
let value;
try {
value = typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch {
return false;
}
return (
hasExactKeys(value, [
'version',
'buildName',
'gear',
'weapons',
'chips',
'protocol',
'conditionalEffects',
'combatProfile',
'calculationMode',
'skillActivations',
'specialization',
]) &&
value.version === 5 &&
typeof value.buildName === 'string' &&
['maximum', 'custom'].includes(value.calculationMode) &&
hasExactKeys(value.gear, gearSlots) &&
gearSlots.every((slot) => isLegacyGearSelection(value.gear[slot])) &&
Array.isArray(value.weapons) &&
value.weapons.length === 2 &&
value.weapons.every(isLegacyWeaponSelection) &&
Array.isArray(value.chips) &&
value.chips.length === 9 &&
value.chips.every(isChipSelection) &&
isProtocolSelection(value.protocol) &&
isEffectStates(value.conditionalEffects) &&
isCombatProfile(value.combatProfile) &&
isSkillActivations(value.skillActivations) &&
hasExactKeys(value.specialization, ['focusId', 'nodeRanks']) &&
typeof value.specialization.focusId === 'string' &&
isRecord(value.specialization.nodeRanks) &&
Object.values(value.specialization.nodeRanks).every(isFiniteNumber)
);
};
const databasePath = resolve(
process.env.TDR_DATABASE_PATH || './data/shd-build.db',
);
@@ -669,6 +769,44 @@ for (const statement of statements) {
await database.exec(statement);
}
await database.exec(`CREATE TRIGGER IF NOT EXISTS specialization_ability_skill_chip_links_validate_insert
BEFORE INSERT ON specialization_ability_skill_chip_links
BEGIN
SELECT CASE WHEN EXISTS (
SELECT 1 FROM specialization_ability_skill_chip_links link
WHERE link.ability_id IN (NEW.ability_id,NEW.compatible_ability_id)
OR link.compatible_ability_id IN (NEW.ability_id,NEW.compatible_ability_id)
) THEN RAISE(ABORT,'Each ability can share one Skill Chip pool') END;
SELECT CASE WHEN NOT EXISTS (
SELECT 1 FROM specialization_abilities ability
JOIN specialization_abilities compatible
ON compatible.id=NEW.compatible_ability_id
WHERE ability.id=NEW.ability_id
AND ability.active=1
AND compatible.active=1
AND ability.specialization_id=compatible.specialization_id
) THEN RAISE(ABORT,'Skill Chip sharing requires active abilities from one specialization') END;
END`);
await database.exec(`CREATE TRIGGER IF NOT EXISTS specialization_ability_skill_chip_links_validate_update
BEFORE UPDATE ON specialization_ability_skill_chip_links
BEGIN
SELECT CASE WHEN EXISTS (
SELECT 1 FROM specialization_ability_skill_chip_links link
WHERE link.rowid<>OLD.rowid
AND (link.ability_id IN (NEW.ability_id,NEW.compatible_ability_id)
OR link.compatible_ability_id IN (NEW.ability_id,NEW.compatible_ability_id))
) THEN RAISE(ABORT,'Each ability can share one Skill Chip pool') END;
SELECT CASE WHEN NOT EXISTS (
SELECT 1 FROM specialization_abilities ability
JOIN specialization_abilities compatible
ON compatible.id=NEW.compatible_ability_id
WHERE ability.id=NEW.ability_id
AND ability.active=1
AND compatible.active=1
AND ability.specialization_id=compatible.specialization_id
) THEN RAISE(ABORT,'Skill Chip sharing requires active abilities from one specialization') END;
END`);
const articleRevisionColumns = await (
await database.prepare("PRAGMA table_info('article_revisions')")
).all();
@@ -2392,6 +2530,32 @@ if (
);
}
const weaponModSlotIconDefinition = await (
await database.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'",
)
).get();
if (
typeof weaponModSlotIconDefinition?.sql === 'string' &&
!weaponModSlotIconDefinition.sql.includes("'weapon_mod_slot'")
) {
console.log('Adding shared Weapon Mod slot icon storage support.');
await database.exec(`CREATE TABLE game_data_icons_weapon_mod_slots (
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node', 'weapon_mod_slot')),
entity_id TEXT NOT NULL,
storage_key TEXT NOT NULL UNIQUE,
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
updated_at TEXT NOT NULL,
PRIMARY KEY(entity_type, entity_id)
)`);
await database.exec(`INSERT INTO game_data_icons_weapon_mod_slots
SELECT entity_type,entity_id,storage_key,byte_size,updated_at FROM game_data_icons`);
await database.exec('DROP TABLE game_data_icons');
await database.exec(
'ALTER TABLE game_data_icons_weapon_mod_slots RENAME TO game_data_icons',
);
}
const managedImageColumns = await (
await database.prepare("PRAGMA table_info('managed_images')")
).all();
@@ -2433,6 +2597,7 @@ const iconLibraryCategory = {
specialization: 'Specializations',
specialization_ability: 'Specializations',
specialization_node: 'Specializations',
weapon_mod_slot: 'Weapon Mods',
};
const iconLibraryLabel = {
talent: 'Talent',
@@ -2442,6 +2607,17 @@ const iconLibraryLabel = {
specialization: 'Specialization',
specialization_ability: 'Specialization Ability',
specialization_node: 'Specialization Tree Node',
weapon_mod_slot: 'Weapon Mod Slot',
};
const weaponModSlotNames = {
'28ffc3b1-5d98-4273-a9af-67dbff3e5c76': 'Optics',
'c4598f51-2cb0-4a20-a3a7-6d8983a26598': 'Muzzle',
'85ca3493-1871-4d22-950a-ed718fd547e0': 'Underbarrel',
'4d809a35-8285-4bff-be98-a0effe1d257e': 'Magazine',
'0e157cfe-975c-441d-8f49-72983161e7b1': 'Barrel',
'551fd7c8-3e5b-4c89-8437-9b901e89e8aa': 'Upper Receiver',
'72573367-6e91-4312-9cc3-710ede4f078e': 'Lower Receiver',
'8dda5edb-6079-4a23-a556-cd9be4521c70': 'Trigger',
};
const normalizeLibraryTags = (values) =>
[
@@ -2532,6 +2708,13 @@ const linkedIconMetadata = async (entityType, entityId) => {
],
};
}
case 'weapon_mod_slot': {
const name = weaponModSlotNames[entityId];
return {
name: name ? `${name} Slot` : null,
tags: ['weapon mod', 'slot', name],
};
}
default:
return { name: null, tags: [] };
}
@@ -2912,6 +3095,302 @@ if (
await database.exec('PRAGMA foreign_keys=ON');
}
for (const kind of ['weapon', 'gear']) {
const optionTable = `${kind}_mod_secondary_options`;
const valueTable = `${kind}_mod_secondary_quality_values`;
const optionColumns = await (
await database.prepare(`PRAGMA table_info('${optionTable}')`)
).all();
if (!optionColumns.some((column) => column.name === `${kind}_mod_id`))
continue;
const conflictingRanges = await (
await database.prepare(`SELECT stat_attribute_id,quality,
COUNT(DISTINCT CAST(min_value AS TEXT)||'/'||CAST(max_value AS TEXT)) AS variants
FROM ${valueTable}
WHERE quality IN ('Specialized','Superior','High-end')
GROUP BY stat_attribute_id,quality HAVING variants>1`)
).all();
if (conflictingRanges.length) {
throw new Error(
`Cannot create the shared ${kind} Mod secondary pool because existing per-Mod ranges disagree. Reconcile ${conflictingRanges.map((row) => `${row.stat_attribute_id}/${row.quality}`).join(', ')} first.`,
);
}
const incompleteOptions = await (
await database.prepare(`SELECT options.stat_attribute_id,
COUNT(DISTINCT CASE WHEN values_row.quality IN ('Specialized','Superior','High-end') THEN values_row.quality END) AS qualities
FROM ${optionTable} options
LEFT JOIN ${valueTable} values_row ON values_row.${kind}_mod_id=options.${kind}_mod_id
AND values_row.stat_attribute_id=options.stat_attribute_id
GROUP BY options.${kind}_mod_id,options.stat_attribute_id HAVING qualities<>3`)
).all();
if (incompleteOptions.length) {
throw new Error(
`Cannot create the shared ${kind} Mod secondary pool because ${incompleteOptions.map((row) => row.stat_attribute_id).join(', ')} does not have all applicable quality ranges.`,
);
}
console.log(
`Converting ${kind} Mod secondary Attributes to one shared level-scaled pool.`,
);
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
await database.exec(`CREATE TABLE ${optionTable}_shared (
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
)`);
await database.exec(`CREATE TABLE ${valueTable}_shared (
stat_attribute_id TEXT NOT NULL,
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
level_1_min REAL NOT NULL,
level_1_max REAL NOT NULL,
level_20_min REAL NOT NULL,
level_20_max REAL NOT NULL,
PRIMARY KEY(stat_attribute_id,quality),
FOREIGN KEY(stat_attribute_id) REFERENCES ${optionTable}_shared(stat_attribute_id) ON DELETE CASCADE,
CHECK(level_1_max>=level_1_min),
CHECK(level_20_max>=level_20_min),
CHECK(level_20_min>=level_1_min),
CHECK(level_20_max>=level_1_max)
)`);
await database.exec(`INSERT INTO ${optionTable}_shared(stat_attribute_id)
SELECT DISTINCT stat_attribute_id FROM ${optionTable}`);
await database.exec(`INSERT INTO ${valueTable}_shared(
stat_attribute_id,quality,level_1_min,level_1_max,level_20_min,level_20_max
) SELECT stat_attribute_id,quality,MIN(min_value),MAX(max_value),MIN(min_value),MAX(max_value)
FROM ${valueTable}
WHERE quality IN ('Specialized','Superior','High-end')
GROUP BY stat_attribute_id,quality`);
await database.exec(`DROP TABLE ${valueTable}`);
await database.exec(`DROP TABLE ${optionTable}`);
await database.exec(
`ALTER TABLE ${optionTable}_shared RENAME TO ${optionTable}`,
);
await database.exec(
`ALTER TABLE ${valueTable}_shared RENAME TO ${valueTable}`,
);
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
}
const skillChipPrimaryOptionColumns = await (
await database.prepare("PRAGMA table_info('skill_chip_primary_options')")
).all();
if (
skillChipPrimaryOptionColumns.some(
(column) => column.name === 'skill_chip_set_id',
)
) {
const fixedSkillChipAttributes = await (
await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active
FROM stat_attributes
WHERE name IN ('Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity')`)
).get();
if (
Number(fixedSkillChipAttributes?.total) !== 4 ||
Number(fixedSkillChipAttributes?.active) !== 4
)
throw new Error(
'Skill Chip profile migration requires exactly four active fixed primary Attributes.',
);
console.log(
'Converting Skill Chip primary and secondary options to shared quality profiles.',
);
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
await database.exec(
'DROP TABLE IF EXISTS skill_chip_primary_quality_values',
);
await database.exec(
'DROP TABLE IF EXISTS skill_chip_secondary_quality_values',
);
await database.exec(`CREATE TABLE skill_chip_primary_options_shared (
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
)`);
await database.exec(`CREATE TABLE skill_chip_primary_quality_values_shared (
stat_attribute_id TEXT NOT NULL,
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
min_value REAL NOT NULL,
max_value REAL NOT NULL,
PRIMARY KEY(stat_attribute_id,quality),
FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_primary_options_shared(stat_attribute_id) ON DELETE CASCADE,
CHECK(max_value>=min_value),
CHECK(min_value>=0)
)`);
await database.exec(`CREATE TABLE skill_chip_secondary_options_shared (
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
)`);
await database.exec(`CREATE TABLE skill_chip_secondary_quality_values_shared (
stat_attribute_id TEXT NOT NULL,
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
min_value REAL NOT NULL,
max_value REAL NOT NULL,
PRIMARY KEY(stat_attribute_id,quality),
FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_secondary_options_shared(stat_attribute_id) ON DELETE CASCADE,
CHECK(max_value>=min_value),
CHECK(min_value>=0)
)`);
await database.exec(`INSERT INTO skill_chip_primary_options_shared(stat_attribute_id)
SELECT id FROM stat_attributes
WHERE name IN ('Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity')
AND active=1`);
await database.exec(`INSERT INTO skill_chip_primary_quality_values_shared(
stat_attribute_id,quality,min_value,max_value
) SELECT attributes.id,qualities.quality,attributes.skill_chip_min,attributes.skill_chip_max
FROM stat_attributes attributes
CROSS JOIN (
SELECT 'Standard' quality UNION ALL SELECT 'Specialized'
UNION ALL SELECT 'Superior' UNION ALL SELECT 'High-end'
) qualities
WHERE attributes.name IN ('Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity')
AND attributes.active=1
AND attributes.skill_chip_min IS NOT NULL
AND attributes.skill_chip_max IS NOT NULL`);
await database.exec(`INSERT INTO skill_chip_secondary_options_shared(stat_attribute_id)
SELECT DISTINCT stat_attribute_id FROM skill_chip_secondary_options`);
await database.exec(`INSERT INTO skill_chip_secondary_quality_values_shared(
stat_attribute_id,quality,min_value,max_value
) SELECT options.stat_attribute_id,qualities.quality,attributes.skill_chip_min,attributes.skill_chip_max
FROM (SELECT DISTINCT stat_attribute_id FROM skill_chip_secondary_options) options
JOIN stat_attributes attributes ON attributes.id=options.stat_attribute_id
CROSS JOIN (
SELECT 'Specialized' quality UNION ALL SELECT 'Superior' UNION ALL SELECT 'High-end'
) qualities
WHERE attributes.skill_chip_min IS NOT NULL
AND attributes.skill_chip_max IS NOT NULL`);
await database.exec('DROP TABLE skill_chip_primary_options');
await database.exec('DROP TABLE skill_chip_secondary_options');
await database.exec(
'ALTER TABLE skill_chip_primary_options_shared RENAME TO skill_chip_primary_options',
);
await database.exec(
'ALTER TABLE skill_chip_primary_quality_values_shared RENAME TO skill_chip_primary_quality_values',
);
await database.exec(
'ALTER TABLE skill_chip_secondary_options_shared RENAME TO skill_chip_secondary_options',
);
await database.exec(
'ALTER TABLE skill_chip_secondary_quality_values_shared RENAME TO skill_chip_secondary_quality_values',
);
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
}
await database.exec(`INSERT OR IGNORE INTO skill_chip_primary_options(stat_attribute_id)
SELECT id FROM stat_attributes
WHERE active=1 AND name IN (
'Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity'
)`);
const skillChipRangeAttributeColumns = await (
await database.prepare("PRAGMA table_info('stat_attributes')")
).all();
if (
skillChipRangeAttributeColumns.some(
(column) => column.name === 'skill_chip_min',
)
) {
console.log(
'Removing obsolete Attribute-level Skill Chip ranges in favor of shared quality profiles.',
);
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
await database.exec(
'DROP INDEX IF EXISTS stat_attributes_name_unit_nocase_idx',
);
await database.exec(`CREATE TABLE stat_attributes_current (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL CHECK(type IN ('Offensive','Defensive')),
category TEXT NOT NULL CHECK(category IN ('Weapon','Skill','Character')),
calculation_type TEXT NOT NULL CHECK(calculation_type IN ('Additive','Multiplicative')),
unit TEXT NOT NULL DEFAULT '',
gear_min REAL,
gear_max REAL,
gear_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(gear_reference_level BETWEEN 1 AND 50),
weapon_min REAL,
weapon_max REAL,
weapon_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50),
cap_value REAL,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK((gear_min IS NULL AND gear_max IS NULL) OR (gear_min IS NOT NULL AND gear_max IS NOT NULL AND gear_min<=gear_max)),
CHECK((weapon_min IS NULL AND weapon_max IS NULL) OR (weapon_min IS NOT NULL AND weapon_max IS NOT NULL AND weapon_min<=weapon_max)),
CHECK(cap_value IS NULL OR cap_value>=0)
)`);
await database.exec(`INSERT INTO stat_attributes_current(
id,name,description,type,category,calculation_type,unit,
gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level,
cap_value,active,created_at,updated_at
) SELECT id,name,description,type,category,calculation_type,unit,
gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level,
cap_value,active,created_at,updated_at FROM stat_attributes`);
await database.exec('DROP TABLE stat_attributes');
await database.exec(
'ALTER TABLE stat_attributes_current RENAME TO stat_attributes',
);
await database.exec(
'CREATE UNIQUE INDEX stat_attributes_name_unit_nocase_idx ON stat_attributes(name COLLATE NOCASE,unit)',
);
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
}
const [loadoutGearRows, loadoutWeaponRows, loadoutTalentTierRows] =
await Promise.all([
(await database.prepare('SELECT id,tier FROM gear')).all(),
(await database.prepare('SELECT id,exotic_talent_id FROM weapons')).all(),
(await database.prepare('SELECT talent_id,tier FROM talent_tiers')).all(),
]);
const migrateLegacyLoadoutSnapshot = (raw) => {
if (!isLegacyLoadoutSnapshot(raw)) return null;
const value =
typeof raw === 'string' ? JSON.parse(raw) : structuredClone(raw);
for (const slot of gearSlots) {
const item = loadoutGearRows.find(
(entry) => String(entry.id) === String(value.gear[slot].itemId),
);
value.gear[slot].talentTier = Number(item?.tier) >= 2 ? 2 : 1;
}
for (const selection of value.weapons) {
const inheritedTier = Number(selection.tier) >= 2 ? 2 : 1;
selection.talent1Tier = inheritedTier;
selection.talent2Tier = inheritedTier;
const weapon = loadoutWeaponRows.find(
(entry) => String(entry.id) === String(selection.itemId),
);
const exoticTalentId = String(weapon?.exotic_talent_id ?? '');
const hasExoticTierTwo = loadoutTalentTierRows.some(
(entry) =>
String(entry.talent_id) === exoticTalentId && entry.tier === 'II',
);
selection.exoticTalentTier =
inheritedTier === 2 && hasExoticTierTwo ? 2 : 1;
}
value.version = 6;
return isCurrentLoadoutSnapshot(value) ? JSON.stringify(value) : null;
};
const buildColumns = await (
await database.prepare("PRAGMA table_info('builds')")
).all();
@@ -2926,9 +3405,34 @@ if (buildColumns.length) {
await database.exec(
"UPDATE builds SET visibility='private' WHERE visibility='draft'",
);
const builds = await (
let builds = await (
await database.prepare('SELECT id,loadout_json FROM builds')
).all();
const migratedBuilds = builds
.map((build) => ({
id: String(build.id),
loadout: migrateLegacyLoadoutSnapshot(build.loadout_json),
}))
.filter((build) => build.loadout);
if (migratedBuilds.length) {
console.log(
`Migrating ${migratedBuilds.length} build loadout snapshot(s) to version 6 Talent tiers.`,
);
await database.exec('BEGIN');
try {
for (const build of migratedBuilds)
await (
await database.prepare('UPDATE builds SET loadout_json=? WHERE id=?')
).run(build.loadout, build.id);
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
builds = await (
await database.prepare('SELECT id,loadout_json FROM builds')
).all();
}
const obsoleteBuildIds = builds
.filter((build) => !isCurrentLoadoutSnapshot(build.loadout_json))
.map((build) => String(build.id));
@@ -3305,11 +3809,55 @@ if (
);
}
const calibrationCases = await (
const outcomeProbabilityColumns = await (
await database.prepare("PRAGMA table_info('conditional_effect_outcomes')")
).all();
if (
!outcomeProbabilityColumns.some(
(column) => column.name === 'probability_percent',
)
) {
await database.exec(
'ALTER TABLE conditional_effect_outcomes ADD COLUMN probability_percent REAL CHECK(probability_percent IS NULL OR (probability_percent>=0 AND probability_percent<=100))',
);
}
let calibrationCases = await (
await database.prepare(
'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases',
)
).all();
const migratedCalibrationCases = calibrationCases
.map((calibrationCase) => ({
id: String(calibrationCase.id),
loadout: migrateLegacyLoadoutSnapshot(
calibrationCase.loadout_snapshot_json,
),
}))
.filter((calibrationCase) => calibrationCase.loadout);
if (migratedCalibrationCases.length) {
console.log(
`Migrating ${migratedCalibrationCases.length} calibration snapshot(s) to version 6 Talent tiers.`,
);
await database.exec('BEGIN');
try {
for (const calibrationCase of migratedCalibrationCases)
await (
await database.prepare(
'UPDATE calculation_calibration_cases SET loadout_snapshot_json=? WHERE id=?',
)
).run(calibrationCase.loadout, calibrationCase.id);
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
calibrationCases = await (
await database.prepare(
'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases',
)
).all();
}
const obsoleteCalibrationIds = calibrationCases
.filter(
(calibrationCase) =>
+72 -2
View File
@@ -44,21 +44,91 @@ for (const table of backup.tables) {
}
let restoredRows = 0;
const attributeTable = backup.tables.find(
(table) => table.name === 'stat_attributes',
);
const legacySkillChipProfiles = attributeTable?.rows.some(
(row) => 'skill_chip_min' in row || 'skill_chip_max' in row,
);
const legacySkillChipRanges = new Map(
(attributeTable?.rows ?? []).map((row) => [
String(row.id),
{ min: row.skill_chip_min, max: row.skill_chip_max },
]),
);
const restoredLegacySecondaries = new Set();
await database.exec('BEGIN');
try {
for (const table of backup.tables) {
for (const row of table.rows) {
const columns = Object.keys(row);
if (
legacySkillChipProfiles &&
table.name === 'skill_chip_primary_options'
)
continue;
const restoredRow = { ...row };
if (legacySkillChipProfiles && table.name === 'stat_attributes') {
delete restoredRow.skill_chip_min;
delete restoredRow.skill_chip_max;
}
if (
legacySkillChipProfiles &&
table.name === 'skill_chip_secondary_options'
) {
delete restoredRow.skill_chip_set_id;
const statId = String(restoredRow.stat_attribute_id ?? '');
if (restoredLegacySecondaries.has(statId)) continue;
restoredLegacySecondaries.add(statId);
}
const columns = Object.keys(restoredRow);
if (!columns.length) continue;
const columnSql = columns.map(quoteIdentifier).join(', ');
const placeholders = columns.map(() => '?').join(', ');
const insert = await database.prepare(
`INSERT INTO ${quoteIdentifier(table.name)} (${columnSql}) VALUES (${placeholders})`,
);
await insert.run(...columns.map((column) => row[column]));
await insert.run(...columns.map((column) => restoredRow[column]));
restoredRows++;
}
}
if (legacySkillChipProfiles) {
const primaryAttributes = await (
await database.prepare(`SELECT id,active FROM stat_attributes WHERE name IN (
'Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity'
)`)
).all();
if (
primaryAttributes.length !== 4 ||
primaryAttributes.some((attribute) => Number(attribute.active) !== 1)
)
throw new Error(
'Legacy restore requires exactly four active fixed Skill Chip primary Attributes.',
);
for (const attribute of primaryAttributes) {
const statId = String(attribute.id);
await (
await database.prepare(
'INSERT INTO skill_chip_primary_options(stat_attribute_id) VALUES(?)',
)
).run(statId);
const range = legacySkillChipRanges.get(statId);
if (!range || range.min === null || range.max === null) continue;
for (const quality of ['Standard', 'Specialized', 'Superior', 'High-end'])
await (
await database.prepare(`INSERT INTO skill_chip_primary_quality_values
(stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?)`)
).run(statId, quality, range.min, range.max);
}
for (const statId of restoredLegacySecondaries) {
const range = legacySkillChipRanges.get(statId);
if (!range || range.min === null || range.max === null) continue;
for (const quality of ['Specialized', 'Superior', 'High-end'])
await (
await database.prepare(`INSERT INTO skill_chip_secondary_quality_values
(stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?)`)
).run(statId, quality, range.min, range.max);
}
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
+3 -9
View File
@@ -98,8 +98,6 @@ try {
weapon_min: record.weaponRange?.[0] ?? existing?.weapon_min ?? null,
weapon_max: record.weaponRange?.[1] ?? existing?.weapon_max ?? null,
weapon_reference_level: Number(existing?.weapon_reference_level ?? 50),
skill_chip_min: existing?.skill_chip_min ?? null,
skill_chip_max: existing?.skill_chip_max ?? null,
cap_value: existing?.cap_value ?? null,
active: 1,
created_at: String(existing?.created_at ?? now),
@@ -117,7 +115,7 @@ try {
await (
await database.prepare(`UPDATE stat_attributes SET
name=?,description=?,type=?,category=?,calculation_type=?,unit=?,
gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,
gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,
cap_value=?,active=1,updated_at=? WHERE id=?`)
).run(
next.name,
@@ -130,8 +128,6 @@ try {
next.gear_max,
next.weapon_min,
next.weapon_max,
next.skill_chip_min,
next.skill_chip_max,
next.cap_value,
now,
id,
@@ -142,8 +138,8 @@ try {
await database.prepare(`INSERT INTO stat_attributes(
id,name,description,type,category,calculation_type,unit,
gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level,
skill_chip_min,skill_chip_max,cap_value,active,created_at,updated_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`)
cap_value,active,created_at,updated_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`)
).run(
id,
next.name,
@@ -158,8 +154,6 @@ try {
next.weapon_min,
next.weapon_max,
next.weapon_reference_level,
next.skill_chip_min,
next.skill_chip_max,
next.cap_value,
now,
now,
+6 -2
View File
@@ -2,7 +2,11 @@ import { connect } from '@tursodatabase/database';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, dirname, join, resolve } from 'node:path';
import process from 'node:process';
import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs';
import {
ISAC_DATA_FORMAT_VERSION,
ISAC_DATA_TABLES,
quoteIdentifier,
} from './isac-data-tables.mjs';
const databasePath = resolve(
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
@@ -67,7 +71,7 @@ for (const row of [
const snapshot = {
format: 'shd-build-isac-data',
version: 2,
version: ISAC_DATA_FORMAT_VERSION,
createdAt: new Date().toISOString(),
sourceDatabase: basename(databasePath),
totalRows,
+233 -2
View File
@@ -3,7 +3,11 @@ import { spawnSync } from 'node:child_process';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, join, resolve } from 'node:path';
import process from 'node:process';
import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs';
import {
ISAC_DATA_FORMAT_VERSION,
ISAC_DATA_TABLES,
quoteIdentifier,
} from './isac-data-tables.mjs';
const snapshotArgument = process.argv
.slice(2)
@@ -22,7 +26,7 @@ const snapshotPath = resolve(snapshotArgument);
const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8'));
if (
snapshot?.format !== 'shd-build-isac-data' ||
snapshot?.version !== 2 ||
snapshot?.version !== ISAC_DATA_FORMAT_VERSION ||
!Array.isArray(snapshot.tables) ||
!Array.isArray(snapshot.icons)
) {
@@ -39,6 +43,233 @@ if (
'The ISAC snapshot table manifest is incomplete or out of order.',
);
}
const abilityRows = snapshot.tables.find(
(table) => table.name === 'specialization_abilities',
).rows;
const abilitiesById = new Map(
abilityRows.map((row) => [String(row.id ?? ''), row]),
);
const skillChipRows = snapshot.tables.find(
(table) => table.name === 'skill_chip_sets',
).rows;
const activeSkillChipCounts = new Map();
for (const row of skillChipRows) {
const abilityId = String(row.specialization_ability_id ?? '');
if (!abilityId || !abilitiesById.has(abilityId)) {
throw new Error(
`The ISAC snapshot contains a Skill Chip definition with a missing ability reference "${abilityId}".`,
);
}
if (Number(row.active) !== 1) continue;
const count = (activeSkillChipCounts.get(abilityId) ?? 0) + 1;
if (count > 3) {
throw new Error(
`The ISAC snapshot assigns more than three active Skill Chip definitions to ability "${abilityId}".`,
);
}
activeSkillChipCounts.set(abilityId, count);
}
const skillChipLinks = snapshot.tables.find(
(table) => table.name === 'specialization_ability_skill_chip_links',
).rows;
const linkedAbilityIds = new Set();
for (const row of skillChipLinks) {
const abilityId = row.ability_id;
const compatibleAbilityId = row.compatible_ability_id;
if (
typeof abilityId !== 'string' ||
!abilityId ||
typeof compatibleAbilityId !== 'string' ||
!compatibleAbilityId
) {
throw new Error(
'The ISAC snapshot contains a Skill Chip sharing link with malformed ability references.',
);
}
if (abilityId === compatibleAbilityId) {
throw new Error(
`The ISAC snapshot contains a self-referencing Skill Chip sharing link for ability "${abilityId}".`,
);
}
if (abilityId >= compatibleAbilityId) {
throw new Error(
`The ISAC snapshot contains a Skill Chip sharing link that is not in canonical ability order: "${abilityId}", "${compatibleAbilityId}".`,
);
}
const ability = abilitiesById.get(abilityId);
const compatibleAbility = abilitiesById.get(compatibleAbilityId);
if (!ability || !compatibleAbility) {
throw new Error(
`The ISAC snapshot contains a Skill Chip sharing link with a missing ability reference: "${abilityId}", "${compatibleAbilityId}".`,
);
}
if (Number(ability.active) !== 1 || Number(compatibleAbility.active) !== 1) {
throw new Error(
`The ISAC snapshot contains a Skill Chip sharing link with an inactive ability: "${abilityId}", "${compatibleAbilityId}".`,
);
}
if (ability.specialization_id !== compatibleAbility.specialization_id) {
throw new Error(
`The ISAC snapshot contains a cross-specialization Skill Chip sharing link: "${abilityId}", "${compatibleAbilityId}".`,
);
}
if (
linkedAbilityIds.has(abilityId) ||
linkedAbilityIds.has(compatibleAbilityId)
) {
throw new Error(
'The ISAC snapshot contains more than one Skill Chip sharing link touching an ability.',
);
}
linkedAbilityIds.add(abilityId);
linkedAbilityIds.add(compatibleAbilityId);
}
for (const kind of ['weapon', 'gear']) {
const options = snapshot.tables.find(
(table) => table.name === `${kind}_mod_secondary_options`,
).rows;
const values = snapshot.tables.find(
(table) => table.name === `${kind}_mod_secondary_quality_values`,
).rows;
const optionIds = options.map((row) => String(row.stat_attribute_id ?? ''));
if (
optionIds.some((id) => !id) ||
new Set(optionIds).size !== optionIds.length ||
(optionIds.length !== 0 &&
(optionIds.length < 2 || optionIds.length > 50)) ||
values.length !== optionIds.length * 3
) {
throw new Error(
`The ISAC snapshot contains an incomplete shared ${kind} Mod secondary pool.`,
);
}
for (const statId of optionIds) {
const profile = values.filter(
(row) => String(row.stat_attribute_id) === statId,
);
if (
profile.length !== 3 ||
['Specialized', 'Superior', 'High-end'].some(
(quality) => !profile.some((row) => row.quality === quality),
) ||
profile.some((row) => {
const endpoints = [
row.level_1_min,
row.level_1_max,
row.level_20_min,
row.level_20_max,
];
return (
endpoints.some(
(value) => typeof value !== 'number' || !Number.isFinite(value),
) ||
endpoints[1] < endpoints[0] ||
endpoints[2] < endpoints[0] ||
endpoints[3] < endpoints[1] ||
endpoints[3] < endpoints[2]
);
})
) {
throw new Error(
`The ISAC snapshot contains an invalid shared ${kind} Mod secondary profile for ${statId}.`,
);
}
}
if (
values.some(
(row) => !optionIds.includes(String(row.stat_attribute_id ?? '')),
)
) {
throw new Error(
`The ISAC snapshot contains an orphaned shared ${kind} Mod secondary range.`,
);
}
}
for (const [profileName, qualities, requiredOptions] of [
['primary', ['Standard', 'Specialized', 'Superior', 'High-end'], 4],
['secondary', ['Specialized', 'Superior', 'High-end'], null],
]) {
const options = snapshot.tables.find(
(table) => table.name === `skill_chip_${profileName}_options`,
).rows;
const values = snapshot.tables.find(
(table) => table.name === `skill_chip_${profileName}_quality_values`,
).rows;
const optionIds = options.map((row) => String(row.stat_attribute_id ?? ''));
const validCardinality = requiredOptions
? optionIds.length === requiredOptions
: optionIds.length <= 50;
if (
!validCardinality ||
optionIds.some((id) => !id) ||
new Set(optionIds).size !== optionIds.length
) {
throw new Error(
`The ISAC snapshot contains an incomplete shared Skill Chip ${profileName} profile.`,
);
}
if (profileName === 'primary' && optionIds.length) {
const attributes = snapshot.tables.find(
(table) => table.name === 'stat_attributes',
).rows;
const expectedIds = attributes
.filter(
(row) =>
Number(row.active) === 1 &&
[
'Skill Duration',
'Skill Radius',
'Skill Cooldown Recovery',
'Skill Intensity',
].includes(String(row.name)),
)
.map((row) => String(row.id))
.sort();
if (
expectedIds.length !== 4 ||
optionIds
.slice()
.sort()
.some((id, index) => id !== expectedIds[index])
)
throw new Error(
'The ISAC snapshot does not use the four fixed Skill Chip primary Attributes.',
);
}
for (const statId of optionIds) {
const rows = values.filter(
(row) => String(row.stat_attribute_id ?? '') === statId,
);
if (!rows.length) continue;
if (
rows.length !== qualities.length ||
qualities.some(
(quality) => !rows.some((row) => row.quality === quality),
) ||
rows.some(
(row) =>
typeof row.min_value !== 'number' ||
!Number.isFinite(row.min_value) ||
typeof row.max_value !== 'number' ||
!Number.isFinite(row.max_value) ||
row.min_value < 0 ||
row.max_value < row.min_value,
)
) {
throw new Error(
`The ISAC snapshot contains an invalid shared Skill Chip ${profileName} profile for ${statId}.`,
);
}
}
if (
values.some(
(row) => !optionIds.includes(String(row.stat_attribute_id ?? '')),
)
)
throw new Error(
`The ISAC snapshot contains an orphaned shared Skill Chip ${profileName} range.`,
);
}
for (const icon of snapshot.icons) {
if (
typeof icon.storageKey !== 'string' ||
+5
View File
@@ -32,7 +32,9 @@ export const ISAC_DATA_TABLES = [
'skill_chip_sets',
'skill_chip_two_piece_bonuses',
'skill_chip_primary_options',
'skill_chip_primary_quality_values',
'skill_chip_secondary_options',
'skill_chip_secondary_quality_values',
'skill_chip_talents',
'skill_chip_talent_effects',
'weapon_mods',
@@ -55,6 +57,7 @@ export const ISAC_DATA_TABLES = [
'specialization_combat_effects',
'specialization_focuses',
'specialization_abilities',
'specialization_ability_skill_chip_links',
'specialization_ability_attacks',
'specialization_ability_effects',
'specialization_focus_abilities',
@@ -73,5 +76,7 @@ export const ISAC_DATA_TABLES = [
'managed_images',
];
export const ISAC_DATA_FORMAT_VERSION = 5;
export const quoteIdentifier = (identifier) =>
`"${String(identifier).replaceAll('"', '""')}"`;
+1 -1
View File
@@ -23,7 +23,7 @@ const images = [];
for (const row of rows) {
if (
typeof row.storage_key !== 'string' ||
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i.test(
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i.test(
row.storage_key,
)
) {
+16 -3
View File
@@ -14,6 +14,19 @@ if (!bundleArgument || !process.argv.includes('--confirm-merge')) {
process.exit(2);
}
const updateExisting = process.argv.includes('--update-existing');
const gameDataRenderSourceTypes = new Set([
'weapon_render',
'gear_render',
'weapon_mod_render',
'gear_mod_render',
'talent_render',
'brand_set_render',
'os_protocol_render',
'skill_chip_render',
'specialization_render',
'specialization_ability_render',
'specialization_node_render',
]);
const bundlePath = resolve(bundleArgument);
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
if (
@@ -30,21 +43,21 @@ for (const image of bundle.images) {
typeof image?.row?.id !== 'string' ||
!/^[0-9a-f-]{36}$/i.test(image.row.id) ||
typeof image.row.storage_key !== 'string' ||
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i.test(
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i.test(
image.row.storage_key,
) ||
typeof image.contentBase64 !== 'string'
) {
throw new Error('The bundle contains an invalid managed image.');
}
const isItemRender = ['weapon_render', 'gear_render'].includes(
const isGameDataRender = gameDataRenderSourceTypes.has(
image.row.source_entity_type,
);
if (
image.row.source_entity_type &&
(typeof image.row.source_entity_id !== 'string' ||
!/^[0-9a-f-]{36}$/i.test(image.row.source_entity_id) ||
(isItemRender
(isGameDataRender
? !image.row.storage_key.startsWith('library-')
: !image.row.storage_key.startsWith(
`${image.row.source_entity_type}-`,
+814 -22
View File
File diff suppressed because it is too large Load Diff
+166
View File
@@ -91,6 +91,172 @@ describe('article documents', () => {
).toBeNull();
});
it('accepts and canonicalizes metadata emitted by the Tiptap editor', () => {
const parsed = parseArticleDocument({
version: 1,
document: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Read more',
marks: [
{
type: 'link',
attrs: {
href: 'https://shd.build/news',
target: '_blank',
rel: 'noopener noreferrer nofollow',
class: null,
title: 'News archive',
},
},
],
},
],
},
{
type: 'orderedList',
attrs: { start: 1, type: null },
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'First item' }],
},
],
},
],
},
],
},
});
expect(parsed?.document.document.content).toEqual([
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Read more',
marks: [
{
type: 'link',
attrs: {
href: 'https://shd.build/news',
title: 'News archive',
},
},
],
},
],
},
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'First item' }],
},
],
},
],
},
]);
expect(parsed?.plainText).toContain('News archive');
});
it('preserves supported ordered-list numbering', () => {
const parsed = parseArticleDocument({
version: 1,
document: {
type: 'doc',
content: [
{
type: 'orderedList',
attrs: { start: 2, type: 'A' },
content: [
{
type: 'listItem',
content: [{ type: 'paragraph' }],
},
],
},
],
},
});
expect(parsed?.document.document.content?.[0]).toMatchObject({
type: 'orderedList',
attrs: { start: 2, type: 'A' },
});
expect(parseArticleDocument(parsed?.document)?.document).toEqual(
parsed?.document,
);
});
it('rejects unknown link metadata and non-default ordered lists', () => {
const documentWith = (content: unknown[]) => ({
version: 1,
document: { type: 'doc', content },
});
expect(
parseArticleDocument(
documentWith([
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'unsafe metadata',
marks: [
{
type: 'link',
attrs: {
href: 'https://shd.build',
onclick: 'alert(1)',
},
},
],
},
],
},
]),
),
).toBeNull();
expect(
parseArticleDocument(
documentWith([
{
type: 'orderedList',
attrs: { start: 0, type: 'decimal' },
content: [],
},
]),
),
).toBeNull();
expect(
parseArticleDocument(
documentWith([
{
type: 'orderedList',
attrs: { start: 1, type: 1 },
content: [],
},
]),
),
).toBeNull();
});
it('rejects duplicate embed keys and unknown envelope fields', () => {
const embed = {
type: 'buildEmbed',
+63 -5
View File
@@ -10,7 +10,7 @@ const EMBED_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{7,63}$/;
export type ArticleMark = {
type: 'bold' | 'italic' | 'code' | 'link';
attrs?: { href: string };
attrs?: { href: string; title?: string };
};
export type ArticleNode = {
@@ -53,7 +53,17 @@ const safeLink = (value: unknown) => {
}
};
function parseMarks(value: unknown): ArticleMark[] | null {
const nullableBoundedString = (value: unknown, maximum: number) =>
value === undefined ||
value === null ||
(typeof value === 'string' &&
value.length <= maximum &&
!/\p{Cc}/u.test(value));
function parseMarks(
value: unknown,
onLinkTitle: (title: string) => void,
): ArticleMark[] | null {
if (value === undefined) return [];
if (!Array.isArray(value) || value.length > 4) return null;
const seen = new Set<string>();
@@ -77,10 +87,31 @@ function parseMarks(value: unknown): ArticleMark[] | null {
!isRecord(candidate.attrs)
)
return null;
if (!exactKeys(candidate.attrs, ['href'])) return null;
if (
!exactKeys(candidate.attrs, [
'href',
'target',
'rel',
'class',
'title',
]) ||
!nullableBoundedString(candidate.attrs.target, 100) ||
!nullableBoundedString(candidate.attrs.rel, 500) ||
!nullableBoundedString(candidate.attrs.class, 500) ||
!nullableBoundedString(candidate.attrs.title, 500)
)
return null;
const href = safeLink(candidate.attrs.href);
if (!href) return null;
marks.push({ type: 'link', attrs: { href } });
const title =
typeof candidate.attrs.title === 'string'
? candidate.attrs.title.normalize('NFKC').trim()
: '';
if (title) onLinkTitle(title);
marks.push({
type: 'link',
attrs: { href, ...(title ? { title } : {}) },
});
}
return marks;
}
@@ -134,7 +165,9 @@ export function parseArticleDocument(value: unknown): {
if (/\p{Cc}/u.test(text.replace(/[\n\t]/g, ''))) return null;
textLength += [...text].length;
if (textLength > MAX_ARTICLE_TEXT_CHARACTERS) return null;
const marks = parseMarks(candidate.marks);
const marks = parseMarks(candidate.marks, (title) =>
textParts.push(title),
);
if (!marks) return null;
textParts.push(text);
return marks.length ? { type, text, marks } : { type, text };
@@ -211,6 +244,31 @@ export function parseArticleDocument(value: unknown): {
)
return null;
attrs = { level: Number(candidate.attrs.level) };
} else if (type === 'orderedList' && candidate.attrs !== undefined) {
if (
!isRecord(candidate.attrs) ||
!exactKeys(candidate.attrs, ['start', 'type'])
)
return null;
const start = candidate.attrs.start ?? 1;
const listType = candidate.attrs.type;
if (
!Number.isInteger(start) ||
Number(start) < 1 ||
Number(start) > 1_000_000 ||
(listType !== null &&
listType !== undefined &&
(typeof listType !== 'string' ||
!['1', 'a', 'A', 'i', 'I'].includes(listType)))
)
return null;
const canonicalType =
typeof listType === 'string' && listType !== '1' ? listType : undefined;
attrs = {
...(Number(start) === 1 ? {} : { start: Number(start) }),
...(canonicalType ? { type: canonicalType } : {}),
};
if (!Object.keys(attrs).length) attrs = undefined;
} else if (candidate.attrs !== undefined) {
if (!isRecord(candidate.attrs) || Object.keys(candidate.attrs).length)
return null;
+2 -1
View File
@@ -259,7 +259,7 @@ describe('loadout mechanic timeline', () => {
).toEqual([1, 2]);
});
it('falls back to a documented Tier I mechanic when equipment Tier II is absent', () => {
it('falls back to a documented Tier I mechanic when exotic Tier II is absent', () => {
const persistedRule = {
rule: {
id: 'tier-i-only',
@@ -327,6 +327,7 @@ describe('loadout mechanic timeline', () => {
const adapted = equippedConditionalProgram({
key: 'weapon:0:exotic-talent',
tier: 2,
isExotic: true,
rule: persistedRule,
});
+1 -1
View File
@@ -283,7 +283,7 @@ export function equippedConditionalProgram(
: 'base';
return (
conditionalProgram(equipped.rule, variant) ??
(equipmentTalent && variant === 'II'
(equipmentTalent && equipped.isExotic && variant === 'II'
? conditionalProgram(equipped.rule, 'I')
: null)
);
+259 -8
View File
@@ -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: [],
});
@@ -206,7 +207,7 @@ describe('summarizeLoadout', () => {
});
expect(summary.equippedTalents).toEqual([
expect.objectContaining({
key: 'chips:chip-set',
key: 'chips:chip-set:ability',
tier: 1,
abilityId: 'ability',
description: 'Gain 25% Skill Damage.',
@@ -214,6 +215,166 @@ describe('summarizeLoadout', () => {
]);
});
it('keeps Skill Chip primaries ability-scoped while adding secondaries to Agent stats', () => {
const draft = createDraft();
draft.specialization.focusId = 'focus-1';
draft.chips[0] = {
setId: 'chip-set',
quality: 'Superior',
primary: { statId: 'skill-duration', value: 12 },
secondary: [
{ statId: 'agent-stat', value: 4 },
{ statId: 'other-secondary', value: 6 },
],
};
const summary = summarizeLoadout(
draft,
catalog({
attributes: [
attribute('skill-duration', 'Skill Duration', { unit: '%' }),
attribute('agent-stat', 'Agent Stat', { unit: '%' }),
attribute('other-secondary', 'Other Secondary', { unit: '%' }),
],
chipSets: [
{
id: 'chip-set',
name: 'Signal',
specialization_ability_id: 'ability-2',
compatibleAbilityIds: ['ability-2', 'ability-1'],
twoPiece: null,
talent: null,
},
],
specializations: [
{
id: 'specialization-1',
focuses: [
{
id: 'focus-1',
abilities: [{ id: 'ability-1' }],
nodes: [],
},
],
},
],
}),
);
expect(stat(summary, 'skill-duration')).toBeUndefined();
expect(stat(summary, 'agent-stat')?.value).toBe(4);
expect(stat(summary, 'other-secondary')?.value).toBe(6);
expect(summary.abilityStats).toEqual([
{
abilityId: 'ability-1',
stats: [
{
id: 'skill-duration',
value: 12,
sourceCount: 1,
sources: [
{
source: 'Skill Chip 1 primary',
chipIndex: 0,
chipSetId: 'chip-set',
value: 12,
},
],
},
],
},
]);
});
it('ignores a shared Skill Chip after its ability link is removed', () => {
const draft = createDraft();
draft.specialization.focusId = 'focus-1';
draft.chips[0] = {
setId: 'chip-set',
quality: 'Standard',
primary: { statId: 'skill-duration', value: 12 },
secondary: [],
};
const summary = summarizeLoadout(
draft,
catalog({
attributes: [
attribute('skill-duration', 'Skill Duration', { unit: '%' }),
],
chipSets: [
{
id: 'chip-set',
name: 'Signal',
specialization_ability_id: 'ability-2',
compatibleAbilityIds: ['ability-2'],
twoPiece: null,
talent: null,
},
],
specializations: [
{
id: 'specialization-1',
focuses: [
{
id: 'focus-1',
abilities: [{ id: 'ability-1' }],
nodes: [],
},
],
},
],
}),
);
expect(summary.abilityStats).toEqual([]);
});
it.each([
[0, 10],
[1, 20],
[2, 30],
[3, 40],
])(
'scales a three-chip 2-piece bonus with %i High-end chips',
(highEndCount, expectedValue) => {
const draft = createDraft();
for (let index = 0; index < 3; index += 1) {
draft.chips[index].setId = 'chip-set';
draft.chips[index].quality =
index < highEndCount ? 'High-end' : 'Standard';
}
const summary = summarizeLoadout(
draft,
catalog({
attributes: [
attribute('skill-damage', 'Skill Damage', { unit: '%' }),
],
chipSets: [
{
id: 'chip-set',
name: 'Signal',
specialization_ability_id: 'ability',
twoPiece: {
stat_attribute_id: 'skill-damage',
min_value: 10,
max_value: 40,
},
talent: null,
},
],
}),
);
expect(stat(summary, 'skill-damage')?.value).toBe(expectedValue);
expect(summary.activeBonuses[0]).toMatchObject({
count: 3,
value: expectedValue,
});
},
);
it('scales Protocol primary stats and Talent descriptions independently', () => {
const draft = createDraft();
draft.protocol = { itemId: 'protocol', level: 50, talentUpgrades: 5 };
@@ -322,11 +483,58 @@ describe('summarizeLoadout', () => {
expect(stat(summary, 'mechanic')).toBeUndefined();
});
it('renders the selected Talent tier without applying its mechanic outcomes to stats', () => {
it('uses a gear Talent tier independently from the gear tier', () => {
const draft = createDraft();
draft.gear.Holster.itemId = 'gear';
draft.gear.Holster.talentId = 'talent';
Object.assign(draft.gear.Holster, { talentTier: 2 });
const summary = summarizeLoadout(
draft,
catalog({
attributes: [attribute('bonus', 'Bonus', { unit: '%' })],
gear: [{ id: 'gear', tier: 0, base_armor_min: null }],
talents: [
{
id: 'talent',
name: 'Reinforced',
template: 'Gain {1}.',
tiers: [
{
tier: 'I',
effects: [{ stat_attribute_id: 'bonus', value: 10 }],
},
{
tier: 'II',
effects: [{ stat_attribute_id: 'bonus', value: 20 }],
},
],
},
],
}),
);
expect(summary.equippedTalents).toEqual([
expect.objectContaining({
id: 'talent',
tier: 2,
tierLabel: 'TIER II',
description: 'Gain 20%.',
}),
]);
});
it('uses each weapon Talent tier independently without applying mechanic outcomes to stats', () => {
const draft = createDraft();
draft.weapons[0].itemId = 'weapon';
draft.weapons[0].tier = 2;
draft.weapons[0].talent1Id = 'talent';
draft.weapons[0].talent1Id = 'talent-one';
draft.weapons[0].talent2Id = 'talent-two';
Object.assign(draft.weapons[0], {
talent1Tier: 1,
talent2Tier: 2,
exoticTalentTier: 1,
});
const rule = {
clauses: [
@@ -352,13 +560,13 @@ describe('summarizeLoadout', () => {
{
id: 'weapon',
name: 'Test Weapon',
exotic_talent_id: null,
exotic_talent_id: 'exotic-talent',
baseBonuses: [],
},
],
talents: [
{
id: 'talent',
id: 'talent-one',
name: 'Measured',
template: 'Gain {1} for {2}.',
tiers: [
@@ -379,17 +587,60 @@ describe('summarizeLoadout', () => {
],
rule,
},
{
id: 'talent-two',
name: 'Calibrated',
template: 'Gain {1}.',
tiers: [
{
tier: 'I',
effects: [{ stat_attribute_id: 'talent-stat', value: 30 }],
},
{
tier: 'II',
effects: [{ stat_attribute_id: 'talent-stat', value: 40 }],
},
],
},
{
id: 'exotic-talent',
name: 'Exotic Talent',
template: 'Gain {1}.',
tiers: [
{
tier: 'I',
effects: [{ stat_attribute_id: 'talent-stat', value: 50 }],
},
{
tier: 'II',
effects: [{ stat_attribute_id: 'talent-stat', value: 60 }],
},
],
},
],
}),
);
expect(summary.equippedTalents).toEqual([
expect.objectContaining({
id: 'talent',
tier: 2,
description: 'Gain 20% for 3 Shots.',
id: 'talent-one',
tier: 1,
tierLabel: 'TIER I',
description: 'Gain 10% for 2 Shots.',
rule,
}),
expect.objectContaining({
id: 'talent-two',
tier: 2,
tierLabel: 'TIER II',
description: 'Gain 40%.',
}),
expect.objectContaining({
id: 'exotic-talent',
tier: 1,
tierLabel: 'TIER I',
description: 'Gain 50%.',
}),
]);
expect(stat(summary, 'talent-stat')).toBeUndefined();
});
+183 -28
View File
@@ -1,4 +1,9 @@
import { gearSlots, scaled, type LoadoutDraft } from '$lib/builder/loadout';
import {
gearSlots,
scaled,
type LoadoutDraft,
type TalentTier,
} from '$lib/builder/loadout';
export type LoadoutCatalog = {
attributes: any[];
@@ -7,6 +12,10 @@ export type LoadoutCatalog = {
brands: any[];
weapons: any[];
talents: any[];
skillChipProfile?: {
primaryOptions: any[];
secondaryOptions: any[];
};
chipSets: any[];
protocols: any[];
specializations: any[];
@@ -14,6 +23,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[];
};
@@ -82,18 +105,21 @@ function unitValue(catalog: LoadoutCatalog, value: unknown, statId: string) {
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
}
function talentTier(catalog: LoadoutCatalog, talentId: string, tier: number) {
function talentTier(
catalog: LoadoutCatalog,
talentId: string,
tier: TalentTier,
) {
const item = catalog.talents.find((entry) => entry.id === talentId);
return (
item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ??
item?.tiers[0]
return item?.tiers.find(
(entry: any) => entry.tier === (tier === 2 ? 'II' : 'I'),
);
}
function talentDescription(
catalog: LoadoutCatalog,
talentId: string,
tier: number,
tier: TalentTier,
) {
const item = catalog.talents.find((entry) => entry.id === talentId);
const configured = talentTier(catalog, talentId, tier);
@@ -139,6 +165,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];
@@ -188,12 +263,14 @@ export function summarizeLoadout(
rule:
catalog.talents.find((entry) => entry.id === selection.talentId)
?.rule ?? null,
tier: Number(item.tier),
tier: selection.talentTier,
tierLabel: selection.talentTier === 2 ? 'TIER II' : 'TIER I',
isExotic: false,
source: slot,
description: talentDescription(
catalog,
selection.talentId,
Number(item.tier),
selection.talentTier,
),
});
}
@@ -238,42 +315,98 @@ export function summarizeLoadout(
roll.value,
`${index ? 'Secondary' : 'Primary'} weapon roll`,
);
for (const talentId of [
selection.talent1Id,
selection.talent2Id,
item.exotic_talent_id,
].filter(Boolean)) {
const equippedWeaponTalents = [
{
id: selection.talent1Id,
tier: selection.talent1Tier,
isExotic: false,
},
{
id: selection.talent2Id,
tier: selection.talent2Tier,
isExotic: false,
},
{
id: item.exotic_talent_id,
tier: selection.exoticTalentTier,
isExotic: true,
},
].filter((entry) => entry.id);
for (const equippedTalent of equippedWeaponTalents) {
equippedTalents.push({
key: `weapon:${index}:${talentId}`,
id: talentId,
key: `weapon:${index}:${equippedTalent.id}`,
id: equippedTalent.id,
rule:
catalog.talents.find((entry) => entry.id === talentId)?.rule ?? null,
tier: selection.tier,
catalog.talents.find((entry) => entry.id === equippedTalent.id)
?.rule ?? null,
tier: equippedTalent.tier,
tierLabel: equippedTalent.tier === 2 ? 'TIER II' : 'TIER I',
isExotic: equippedTalent.isExotic,
source: index ? 'Secondary weapon' : 'Primary weapon',
description: talentDescription(catalog, talentId, selection.tier),
description: talentDescription(
catalog,
equippedTalent.id,
equippedTalent.tier,
),
});
}
}
const chipGroups = new Map<
string,
{ count: number; highEnd: number; item: any }
{ count: number; highEnd: number; item: any; abilityId: string }
>();
const focus = selectedFocus(draft, catalog);
const validChipRoll = (
kind: 'primaryOptions' | 'secondaryOptions',
quality: string,
roll: { statId: string; value: number },
) => {
if (!catalog.skillChipProfile) return true;
const range = catalog.skillChipProfile[kind].find(
(option) => option.statId === roll.statId,
)?.ranges?.[quality];
return (
range &&
roll.value >= Number(range.min) &&
roll.value <= Number(range.max)
);
};
for (let index = 0; index < 9; index += 1) {
const selection = draft.chips[index];
const item = catalog.chipSets.find((entry) => entry.id === selection.setId);
if (!item) continue;
add(
selection.primary.statId,
selection.primary.value,
`Skill Chip ${index + 1} primary`,
const slotAbilityId = String(
focus?.abilities?.[Math.floor(index / 3)]?.id ?? '',
);
const compatibleAbilityIds = item.compatibleAbilityIds ?? [
String(item.specialization_ability_id ?? ''),
];
if (slotAbilityId && !compatibleAbilityIds.includes(slotAbilityId))
continue;
const abilityId =
slotAbilityId || String(item.specialization_ability_id ?? '');
if (validChipRoll('primaryOptions', selection.quality, selection.primary))
addAbilityStat(
abilityId,
selection.primary.statId,
selection.primary.value,
index,
String(item.id),
);
for (const roll of selection.secondary)
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item };
if (validChipRoll('secondaryOptions', selection.quality, roll))
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
const groupKey = `${item.id}:${abilityId}`;
const group = chipGroups.get(groupKey) ?? {
count: 0,
highEnd: 0,
item,
abilityId,
};
group.count += 1;
if (selection.quality === 'High-end') group.highEnd += 1;
chipGroups.set(item.id, group);
chipGroups.set(groupKey, group);
}
for (const group of chipGroups.values()) {
if (group.count >= 2 && group.item.twoPiece) {
@@ -298,12 +431,12 @@ export function summarizeLoadout(
}
if (group.count === 3 && group.item.talent) {
equippedTalents.push({
key: `chips:${group.item.id}`,
key: `chips:${group.item.id}:${group.abilityId}`,
id: '',
custom: group.item.talent,
rule: group.item.talent.rule ?? null,
tier: group.highEnd,
abilityId: group.item.specialization_ability_id ?? null,
abilityId: group.abilityId || null,
source: '3 Skill Chips',
description: group.item.talent.description_template.replace(
/\{(\d+)\}/g,
@@ -390,6 +523,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,
};
+185 -2
View File
@@ -1,20 +1,91 @@
import { describe, expect, it } from 'vitest';
import { createDraft, parseLoadoutDraft } from './loadout.js';
import {
createDraft,
migrateLoadoutDraftV5,
parseLoadoutDraft,
skillChipSecondaryCapacity,
type SkillChipQuality,
} from './loadout.js';
const copyDraft = () => structuredClone(createDraft());
const talentTierFields = [
[
'gear talentTier',
(draft: ReturnType<typeof copyDraft>) =>
draft.gear.Holster as unknown as Record<string, unknown>,
'talentTier',
],
[
'weapon talent1Tier',
(draft: ReturnType<typeof copyDraft>) =>
draft.weapons[0] as unknown as Record<string, unknown>,
'talent1Tier',
],
[
'weapon talent2Tier',
(draft: ReturnType<typeof copyDraft>) =>
draft.weapons[0] as unknown as Record<string, unknown>,
'talent2Tier',
],
[
'weapon exoticTalentTier',
(draft: ReturnType<typeof copyDraft>) =>
draft.weapons[0] as unknown as Record<string, unknown>,
'exoticTalentTier',
],
] as const;
describe('parseLoadoutDraft', () => {
it('accepts a current draft and returns an independent clone', () => {
const draft = createDraft();
const parsed = parseLoadoutDraft(draft);
expect(parsed).toEqual(draft);
expect(parsed?.version).toBe(6);
expect(parsed?.chips[0]).toEqual({
setId: '',
quality: 'Standard',
primary: { statId: '', value: 0 },
secondary: [],
});
expect(parsed?.calculationMode).toBe('maximum');
expect(parsed).not.toBe(draft);
parsed!.gear.Holster.level = 1;
expect(draft.gear.Holster.level).toBe(50);
});
it('defaults every equipment Talent tier to Tier I', () => {
const draft = createDraft();
expect(
Object.values(draft.gear).map(
(selection) =>
(selection as unknown as Record<string, unknown>).talentTier,
),
).toEqual(Array(6).fill(1));
expect(
draft.weapons.map((selection) => {
const weapon = selection as unknown as Record<string, unknown>;
return [
weapon.talent1Tier,
weapon.talent2Tier,
weapon.exoticTalentTier,
];
}),
).toEqual([
[1, 1, 1],
[1, 1, 1],
]);
});
it('accepts Tier II for every equipment Talent selection', () => {
const draft = copyDraft();
for (const [, select, key] of talentTierFields) select(draft)[key] = 2;
expect(parseLoadoutDraft(draft)).toEqual(draft);
});
it('rejects the retired version 1 three-chip shape', () => {
const retired = copyDraft() as unknown as Record<string, unknown>;
retired.version = 1;
@@ -23,13 +94,125 @@ describe('parseLoadoutDraft', () => {
expect(parseLoadoutDraft(retired)).toBeNull();
});
it('rejects a version 4 draft that does not contain exactly nine chips', () => {
it('rejects the retired version 4 schema', () => {
const retired = copyDraft() as unknown as Record<string, unknown>;
retired.version = 4;
expect(parseLoadoutDraft(retired)).toBeNull();
});
it('rejects the retired version 5 schema', () => {
const retired = copyDraft() as unknown as Record<string, unknown>;
retired.version = 5;
expect(parseLoadoutDraft(retired)).toBeNull();
});
it('migrates version 5 equipment-derived Talent tiers once', () => {
const legacy = copyDraft() as unknown as Record<string, any>;
legacy.version = 5;
for (const selection of Object.values(legacy.gear) as Record<
string,
any
>[]) {
delete selection.talentTier;
}
for (const selection of legacy.weapons as Record<string, any>[]) {
delete selection.talent1Tier;
delete selection.talent2Tier;
delete selection.exoticTalentTier;
}
legacy.gear.Holster.itemId = 'tier-two-gear';
legacy.weapons[0].itemId = 'exotic-weapon';
legacy.weapons[0].tier = 2;
const migrated = migrateLoadoutDraftV5(legacy, {
gear: [{ id: 'tier-two-gear', tier: 2 }],
weapons: [{ id: 'exotic-weapon', exotic_talent_id: 'exotic-talent' }],
talents: [
{
id: 'exotic-talent',
tiers: [{ tier: 'I' }, { tier: 'II' }],
},
],
});
expect(migrated?.version).toBe(6);
expect(migrated?.gear.Holster.talentTier).toBe(2);
expect(migrated?.gear.Gloves.talentTier).toBe(1);
expect(migrated?.weapons[0]).toMatchObject({
talent1Tier: 2,
talent2Tier: 2,
exoticTalentTier: 2,
});
});
it('rejects a current draft that does not contain exactly nine chips', () => {
const draft = copyDraft();
draft.chips.pop();
expect(parseLoadoutDraft(draft)).toBeNull();
});
it.each(talentTierFields)('rejects a draft missing %s', (_, select, key) => {
const draft = copyDraft();
delete select(draft)[key];
expect(parseLoadoutDraft(draft)).toBeNull();
});
it.each(
talentTierFields.flatMap(([name, select, key]) =>
[0, 1.5, 3].map((value) => [name, value, select, key] as const),
),
)('rejects %s value %s', (_, value, select, key) => {
const draft = copyDraft();
select(draft)[key] = value;
expect(parseLoadoutDraft(draft)).toBeNull();
});
it.each([
['Standard', 0],
['Specialized', 1],
['Superior', 2],
['High-end', 2],
] as const)(
'accepts %s chips with exactly %i secondary rolls',
(quality, capacity) => {
const draft = copyDraft();
draft.chips[0].quality = quality;
draft.chips[0].primary = { statId: 'primary', value: 10 };
draft.chips[0].secondary = Array.from(
{ length: capacity },
(_, index) => ({ statId: `secondary-${index}`, value: index + 1 }),
);
expect(skillChipSecondaryCapacity[quality]).toBe(capacity);
expect(parseLoadoutDraft(draft)).toEqual(draft);
},
);
it.each([
['Standard', 1],
['Specialized', 0],
['Specialized', 2],
['Superior', 1],
['High-end', 3],
] as Array<[SkillChipQuality, number]>)(
'rejects %s chips with %i secondary rolls',
(quality, capacity) => {
const draft = copyDraft();
draft.chips[0].quality = quality;
draft.chips[0].secondary = Array.from(
{ length: capacity },
(_, index) => ({ statId: `secondary-${index}`, value: index + 1 }),
);
expect(parseLoadoutDraft(draft)).toBeNull();
},
);
it('rejects a draft missing the unified combat profile', () => {
const draft = copyDraft() as unknown as Record<string, unknown>;
delete draft.combatProfile;
+77 -9
View File
@@ -1,9 +1,24 @@
export type Roll = { statId: string; value: number };
export type TalentTier = 1 | 2;
export const skillChipQualities = [
'Standard',
'Specialized',
'Superior',
'High-end',
] as const;
export type SkillChipQuality = (typeof skillChipQualities)[number];
export const skillChipSecondaryCapacity: Record<SkillChipQuality, number> = {
Standard: 0,
Specialized: 1,
Superior: 2,
'High-end': 2,
};
export type GearSelection = {
itemId: string;
level: number;
brandId: string;
talentId: string;
talentTier: TalentTier;
health: number;
armor: number;
firepower: number;
@@ -21,14 +36,17 @@ export type WeaponSelection = {
toughness: number;
engineering: number;
talent1Id: string;
talent1Tier: TalentTier;
talent2Id: string;
talent2Tier: TalentTier;
exoticTalentTier: TalentTier;
rolls: [Roll, Roll];
};
export type ChipSelection = {
setId: string;
quality: 'Superior' | 'High-end';
quality: SkillChipQuality;
primary: Roll;
secondary: [Roll, Roll];
secondary: Roll[];
};
export type ProtocolSelection = {
itemId: string;
@@ -68,7 +86,7 @@ export type SpecializationSelection = {
nodeRanks: Record<string, number>;
};
export type LoadoutDraft = {
version: 4;
version: 6;
buildName: string;
calculationMode: CalculationMode;
gear: Record<string, GearSelection>;
@@ -105,6 +123,7 @@ export const blankGear = (): GearSelection => ({
level: 50,
brandId: '',
talentId: '',
talentTier: 1,
health: 0,
armor: 0,
firepower: 0,
@@ -122,14 +141,17 @@ export const blankWeapon = (): WeaponSelection => ({
toughness: 0,
engineering: 0,
talent1Id: '',
talent1Tier: 1,
talent2Id: '',
talent2Tier: 1,
exoticTalentTier: 1,
rolls: [blankRoll(), blankRoll()],
});
export const blankChip = (): ChipSelection => ({
setId: '',
quality: 'Superior',
quality: 'Standard',
primary: blankRoll(),
secondary: [blankRoll(), blankRoll()],
secondary: [],
});
export const defaultCombatProfile = (): CombatProfile => ({
encounterSeconds: 60,
@@ -148,7 +170,7 @@ export const defaultCombatProfile = (): CombatProfile => ({
extraHealthActive: false,
});
export const createDraft = (): LoadoutDraft => ({
version: 4,
version: 6,
buildName: 'Untitled loadout',
calculationMode: 'maximum',
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
@@ -174,6 +196,39 @@ export const createDraft = (): LoadoutDraft => ({
},
});
export function migrateLoadoutDraftV5(
value: unknown,
catalog: { gear: any[]; weapons: any[]; talents: any[] },
): LoadoutDraft | null {
if (!isRecord(value) || value.version !== 5) return null;
const migrated = structuredClone(value);
if (!isRecord(migrated.gear) || !Array.isArray(migrated.weapons)) return null;
for (const slot of gearSlots) {
const selection = migrated.gear[slot];
if (!isRecord(selection)) return null;
const item = catalog.gear.find((entry) => entry.id === selection.itemId);
selection.talentTier = Number(item?.tier) >= 2 ? 2 : 1;
}
for (const selection of migrated.weapons) {
if (!isRecord(selection)) return null;
const inheritedTier: TalentTier = Number(selection.tier) >= 2 ? 2 : 1;
selection.talent1Tier = inheritedTier;
selection.talent2Tier = inheritedTier;
const item = catalog.weapons.find((entry) => entry.id === selection.itemId);
const exoticTalent = catalog.talents.find(
(entry) => entry.id === item?.exotic_talent_id,
);
selection.exoticTalentTier =
inheritedTier === 2 &&
exoticTalent?.tiers?.some((entry: any) => entry.tier === 'II')
? 2
: 1;
}
migrated.version = 6;
return parseLoadoutDraft(migrated);
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
@@ -181,6 +236,8 @@ const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
keys.every((key) => key in value);
const isFiniteNumber = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value);
const isTalentTier = (value: unknown): value is TalentTier =>
value === 1 || value === 2;
const isNumberRecord = (value: unknown) =>
isRecord(value) &&
Object.values(value).every(
@@ -200,6 +257,7 @@ const isGearSelection = (value: unknown): value is GearSelection =>
'level',
'brandId',
'talentId',
'talentTier',
'health',
'armor',
'firepower',
@@ -211,6 +269,7 @@ const isGearSelection = (value: unknown): value is GearSelection =>
['itemId', 'brandId', 'talentId'].every(
(key) => typeof value[key] === 'string',
) &&
isTalentTier(value.talentTier) &&
['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every(
(key) => isFiniteNumber(value[key]),
) &&
@@ -228,12 +287,18 @@ const isWeaponSelection = (value: unknown): value is WeaponSelection =>
'toughness',
'engineering',
'talent1Id',
'talent1Tier',
'talent2Id',
'talent2Tier',
'exoticTalentTier',
'rolls',
]) &&
['itemId', 'talent1Id', 'talent2Id'].every(
(key) => typeof value[key] === 'string',
) &&
isTalentTier(value.talent1Tier) &&
isTalentTier(value.talent2Tier) &&
isTalentTier(value.exoticTalentTier) &&
['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every(
(key) => isFiniteNumber(value[key]),
) &&
@@ -242,9 +307,12 @@ const isChipSelection = (value: unknown): value is ChipSelection =>
isRecord(value) &&
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
typeof value.setId === 'string' &&
(value.quality === 'Superior' || value.quality === 'High-end') &&
skillChipQualities.some((quality) => value.quality === quality) &&
isRoll(value.primary) &&
isRollPair(value.secondary);
Array.isArray(value.secondary) &&
value.secondary.length ===
skillChipSecondaryCapacity[value.quality as SkillChipQuality] &&
value.secondary.every(isRoll);
const isProtocolSelection = (value: unknown): value is ProtocolSelection =>
isRecord(value) &&
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) &&
@@ -353,7 +421,7 @@ export function parseLoadoutDraft(value: unknown): LoadoutDraft | null {
'skillActivations',
'specialization',
]) ||
value.version !== 4 ||
value.version !== 6 ||
typeof value.buildName !== 'string' ||
!['maximum', 'custom'].includes(String(value.calculationMode))
)
+146
View File
@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest';
import {
calculateWeaponBaseline,
type WeaponBaselineInput,
} from './weapon-baseline.js';
const input = (
overrides: Partial<WeaponBaselineInput> = {},
): WeaponBaselineInput => ({
weaponId: 'weapon-17',
weaponName: 'Evidence Rifle',
weaponLabel: 'PRIMARY',
selectedDamage: 12.75,
rpm: 137.5,
magazine: 31,
reloadSeconds: 2.35,
...overrides,
});
describe('calculateWeaponBaseline', () => {
it('preserves decimal inputs in the selected and rated metrics', () => {
const baseline = calculateWeaponBaseline(input());
expect(baseline.selectedDamage).toEqual({ value: 12.75, missing: [] });
expect(baseline.ratedBodyHitThroughput).toEqual({
value: 29.21875,
missing: [],
});
});
it('keeps rated throughput available when reload inputs are missing', () => {
const baseline = calculateWeaponBaseline(
input({ magazine: undefined, reloadSeconds: undefined }),
);
expect(baseline.ratedBodyHitThroughput).toEqual({
value: 29.21875,
missing: [],
});
expect(baseline.magazineCycleBodyHitThroughput).toEqual({
value: null,
missing: [
'magazine is unavailable for weapon weapon-17',
'reloadSeconds is unavailable for weapon weapon-17',
],
});
});
it('uses the complete magazine-cycle formula without rounding', () => {
const baseline = calculateWeaponBaseline(
input({
selectedDamage: 13.25,
rpm: 97.5,
magazine: 17.5,
reloadSeconds: 2.75,
}),
);
const expected = (13.25 * 17.5) / (17.5 / (97.5 / 60) + 2.75);
expect(baseline.magazineCycleBodyHitThroughput).toEqual({
value: expected,
missing: [],
});
});
it('uses selected damage exactly once at the no-double-scaling boundary', () => {
const baseline = calculateWeaponBaseline(
input({ selectedDamage: 12.5, rpm: 120, magazine: 10, reloadSeconds: 5 }),
);
expect(baseline.selectedDamage.value).toBe(12.5);
expect(baseline.ratedBodyHitThroughput.value).toBe(12.5 * (120 / 60));
expect(baseline.magazineCycleBodyHitThroughput.value).toBe(
(12.5 * 10) / (10 / (120 / 60) + 5),
);
expect(baseline.ratedBodyHitThroughput.value).not.toBe(
12.5 * 12.5 * (120 / 60),
);
expect(baseline.magazineCycleBodyHitThroughput.value).not.toBe(12.5 * 12.5);
});
it.each([
null,
undefined,
0,
-1,
Number.NaN,
Number.POSITIVE_INFINITY,
Number.NEGATIVE_INFINITY,
])('rejects invalid selected damage: %s', (selectedDamage) => {
const baseline = calculateWeaponBaseline(input({ selectedDamage }));
expect(baseline.selectedDamage.value).toBeNull();
expect(baseline.ratedBodyHitThroughput.value).toBeNull();
expect(baseline.magazineCycleBodyHitThroughput.value).toBeNull();
expect(baseline.selectedDamage.missing[0]).toContain('selectedDamage');
expect(baseline.selectedDamage.missing[0]).toContain('weapon-17');
for (const metric of [
baseline.selectedDamage,
baseline.ratedBodyHitThroughput,
baseline.magazineCycleBodyHitThroughput,
]) {
expect(metric.value).toBeNull();
expect(metric.value).not.toBeNaN();
expect(metric.value).not.toBe(Infinity);
expect(metric.value).not.toBe(-Infinity);
}
});
it.each([
['rpm', null],
['rpm', 0],
['rpm', Number.NaN],
['magazine', -1],
['reloadSeconds', Number.POSITIVE_INFINITY],
] as const)(
'makes only dependent metrics unavailable for invalid %s',
(field, value) => {
const baseline = calculateWeaponBaseline(input({ [field]: value }));
expect(baseline.selectedDamage.value).toBe(12.75);
if (field === 'rpm') {
expect(baseline.ratedBodyHitThroughput.value).toBeNull();
expect(baseline.magazineCycleBodyHitThroughput.value).toBeNull();
} else if (field === 'magazine' || field === 'reloadSeconds') {
expect(baseline.ratedBodyHitThroughput.value).toBe(29.21875);
expect(baseline.magazineCycleBodyHitThroughput.value).toBeNull();
}
const dependent =
field === 'rpm'
? [
baseline.ratedBodyHitThroughput,
baseline.magazineCycleBodyHitThroughput,
]
: [baseline.magazineCycleBodyHitThroughput];
for (const metric of dependent) {
expect(metric.value).not.toBeNaN();
expect(metric.value).not.toBe(Infinity);
expect(metric.value).not.toBe(-Infinity);
expect(metric.missing[0]).toContain(field);
expect(metric.missing[0]).toContain('weapon-17');
}
},
);
});
+112
View File
@@ -0,0 +1,112 @@
type NumericSource = number | null | undefined;
export type WeaponBaselineInput = {
weaponId: string;
weaponName: string;
weaponLabel: string;
selectedDamage?: NumericSource;
rpm?: NumericSource;
magazine?: NumericSource;
reloadSeconds?: NumericSource;
};
export type BaselineMetric = {
value: number | null;
missing: string[];
};
export type WeaponBaseline = {
weaponId: string;
weaponName: string;
weaponLabel: string;
selectedDamage: BaselineMetric;
ratedBodyHitThroughput: BaselineMetric;
magazineCycleBodyHitThroughput: BaselineMetric;
};
const isPositiveFinite = (value: NumericSource): value is number =>
typeof value === 'number' && Number.isFinite(value) && value > 0;
const missingField = (field: string, weaponId: string): string =>
`${field} is unavailable for weapon ${weaponId}`;
const unavailableMetric = (
fields: string[],
weaponId: string,
): BaselineMetric => ({
value: null,
missing: fields.map((field) => missingField(field, weaponId)),
});
const calculatedMetric = (
value: number,
metricField: string,
weaponId: string,
): BaselineMetric =>
Number.isFinite(value)
? { value, missing: [] }
: { value: null, missing: [missingField(metricField, weaponId)] };
export function calculateWeaponBaseline(
input: WeaponBaselineInput,
): WeaponBaseline {
const damageAvailable = isPositiveFinite(input.selectedDamage);
const rpmAvailable = isPositiveFinite(input.rpm);
const magazineAvailable = isPositiveFinite(input.magazine);
const reloadAvailable = isPositiveFinite(input.reloadSeconds);
const damage = damageAvailable ? (input.selectedDamage as number) : null;
const rpm = rpmAvailable ? (input.rpm as number) : null;
const magazine = magazineAvailable ? (input.magazine as number) : null;
const reloadSeconds = reloadAvailable
? (input.reloadSeconds as number)
: null;
const selectedDamage =
damage !== null
? calculatedMetric(damage, 'selectedDamage', input.weaponId)
: unavailableMetric(['selectedDamage'], input.weaponId);
const ratedBodyHitThroughput =
damage !== null && rpm !== null
? calculatedMetric(
(damage * rpm) / 60,
'ratedBodyHitThroughput',
input.weaponId,
)
: unavailableMetric(
[
...(!damageAvailable ? ['selectedDamage'] : []),
...(!rpmAvailable ? ['rpm'] : []),
],
input.weaponId,
);
const magazineCycleBodyHitThroughput =
damage !== null &&
rpm !== null &&
magazine !== null &&
reloadSeconds !== null
? calculatedMetric(
(damage * magazine) / (magazine / (rpm / 60) + reloadSeconds),
'magazineCycleBodyHitThroughput',
input.weaponId,
)
: unavailableMetric(
[
...(!damageAvailable ? ['selectedDamage'] : []),
...(!rpmAvailable ? ['rpm'] : []),
...(!magazineAvailable ? ['magazine'] : []),
...(!reloadAvailable ? ['reloadSeconds'] : []),
],
input.weaponId,
);
return {
weaponId: input.weaponId,
weaponName: input.weaponName,
weaponLabel: input.weaponLabel,
selectedDamage,
ratedBodyHitThroughput,
magazineCycleBodyHitThroughput,
};
}
+234 -5
View File
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
import {
currentHealthPercentBandPredicate,
eventFieldComparisonPredicate,
mechanicTargetingForMode,
mechanicTargetingMode,
parseConditionalMechanicDraft,
readCurrentHealthPercentBand,
readEventFieldComparison,
@@ -200,6 +202,110 @@ describe('conditional mechanic authoring payload', () => {
});
});
it('exposes assigned-ability activity for kill and assist event conditions', () => {
expect(registeredEventFields('enemy_killed')).toEqual(
expect.arrayContaining([
{
path: 'values.assignedAbilityActive',
label: 'Assigned ability active',
type: 'boolean',
},
]),
);
expect(registeredEventFields('assist')).toEqual(
expect.arrayContaining([
{
path: 'values.assignedAbilityActive',
label: 'Assigned ability active',
type: 'boolean',
},
]),
);
const value = draft();
value.clauses[0].event = 'enemy_killed';
value.clauses[0].recipeKey = undefined;
value.clauses[0].reducer = { kind: 'none' };
value.clauses[0].if = eventFieldComparisonPredicate({
path: 'values.assignedAbilityActive',
operator: 'eq',
value: true,
});
const result = parseConditionalMechanicDraft(value, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage']),
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.programs.I.clauses[0]).toMatchObject({
when: 'enemy_killed',
if: value.clauses[0].if,
});
});
it('parses deterministic outcome and clause proc chances with ability mutations', () => {
const value = draft();
value.clauses[0].event = 'skill_hit';
value.clauses[0].recipeKey = undefined;
value.clauses[0].procChancePercent = 10;
value.clauses[0].requiresAssignedAbilityActive = true;
value.clauses[0].outcomes[0].probabilityPercent = 29;
value.clauses[0].outcomes[0].statePool = undefined;
value.clauses[0].outcomes[0].perStack = false;
value.pools = [
{
key: 'extension-stacks',
label: 'Extension stacks',
stateKind: 'stack',
scope: 'agent',
initial: 0,
min: 0,
max: 8,
},
{
key: 'extension-cooldown',
label: 'Extension cooldown',
stateKind: 'flag',
scope: 'agent',
initial: 0,
min: 0,
max: 1,
expiresAfterSeconds: 15,
},
];
value.clauses[0].mutations = [
{ kind: 'add', pool: 'extension-stacks', value: 1 },
{ kind: 'extend_duration', target: 'assigned_ability', seconds: 0.3 },
{
kind: 'start_cooldown_on_max',
stackPool: 'extension-stacks',
cooldownPool: 'extension-cooldown',
},
];
const result = parseConditionalMechanicDraft(value, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage']),
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.programs.I.clauses[0]).toMatchObject({
procChancePercent: 10,
requiresAssignedAbilityActive: true,
outcomes: [{ probabilityPercent: 29 }],
});
expect(result.value.programs.I.clauses[0].mutations).toEqual(
expect.arrayContaining([
{ kind: 'extend_duration', target: 'assigned_ability', seconds: 0.3 },
{
kind: 'start_cooldown_on_max',
stackPool: 'extension-stacks',
cooldownPool: 'extension-cooldown',
},
]),
);
});
it('rejects an unregistered Extra Health event field deterministically', () => {
const value = draft();
value.clauses[0].event = 'extra_health_changed';
@@ -685,9 +791,9 @@ describe('conditional mechanic authoring payload', () => {
});
});
it('rejects invalid pre-event timing for current Health percentage references', () => {
const invalid = draft();
invalid.clauses[0].if = {
it('preserves pre-event timing for current Health percentage references', () => {
const value = draft();
value.clauses[0].if = {
kind: 'compare',
left: {
source: 'combat',
@@ -697,11 +803,13 @@ describe('conditional mechanic authoring payload', () => {
operator: 'lt',
right: 50,
};
const result = parseConditionalMechanicDraft(invalid, {
const result = parseConditionalMechanicDraft(value, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage']),
});
expect(result.ok).toBe(false);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.draft.clauses[0].if).toEqual(value.clauses[0].if);
});
it('accepts compatible radius targeting and rejects incompatible scopes', () => {
@@ -735,4 +843,125 @@ describe('conditional mechanic authoring payload', () => {
);
}
});
it('exposes aiming and round-trips event-target explosion editor state', () => {
expect(registeredEventFields('weapon_hit')).toContainEqual({
path: 'values.aiming',
label: 'Aiming down sights',
type: 'boolean',
});
const targeting = mechanicTargetingForMode('event_target_explosion');
expect(targeting).toEqual({
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
});
expect(mechanicTargetingMode(targeting)).toBe('event_target_explosion');
expect(mechanicTargetingForMode('none', targeting)).toBeUndefined();
const valid = draft();
valid.clauses[0].if = eventFieldComparisonPredicate({
path: 'values.aiming',
operator: 'eq',
value: true,
});
valid.clauses[0].outcomes[0] = {
...valid.clauses[0].outcomes[0],
kind: 'direct_damage',
scope: 'target',
phase: 'after_event',
attributeId: undefined,
targeting,
};
const parsed = parseConditionalMechanicDraft(valid, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage']),
});
expect(parsed.ok).toBe(true);
if (parsed.ok) {
expect(parsed.value.draft.clauses[0].outcomes[0].targeting).toEqual(
targeting,
);
}
});
it('rejects incomplete, mis-scoped, and non-event area damage', () => {
for (const configure of [
(candidate: ConditionalMechanicDraftV1) => {
candidate.clauses[0].outcomes[0].targeting = {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 0,
};
},
(candidate: ConditionalMechanicDraftV1) => {
candidate.clauses[0].outcomes[0].scope = 'self';
},
(candidate: ConditionalMechanicDraftV1) => {
candidate.clauses[0].outcomes[0].phase = 'while_active';
},
(candidate: ConditionalMechanicDraftV1) => {
candidate.clauses[0].event = 'enemy_killed';
},
]) {
const invalid = draft();
invalid.clauses[0].outcomes[0] = {
...invalid.clauses[0].outcomes[0],
kind: 'direct_damage',
scope: 'target',
phase: 'after_event',
attributeId: undefined,
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
},
};
configure(invalid);
expect(
parseConditionalMechanicDraft(invalid, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage']),
}).ok,
).toBe(false);
}
});
it('requires explicit equality for optional aiming evidence', () => {
for (const predicate of [
{
kind: 'compare' as const,
left: { source: 'event' as const, path: 'values.aiming' },
operator: 'ne' as const,
right: true,
},
{
kind: 'not' as const,
predicate: {
kind: 'compare' as const,
left: { source: 'event' as const, path: 'values.aiming' },
operator: 'eq' as const,
right: true,
},
},
{
kind: 'compare' as const,
left: { source: 'event' as const, path: 'values.aiming' },
operator: 'ne' as const,
right: { source: 'combat' as const, path: 'currentHealth' },
},
]) {
const invalid = draft();
invalid.clauses[0].if = predicate;
expect(
parseConditionalMechanicDraft(invalid, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage']),
}).ok,
).toBe(false);
}
});
});
+115 -25
View File
@@ -1,12 +1,14 @@
import {
MECHANIC_EVENT_KINDS,
MECHANIC_EVENT_REGISTRY,
mechanicOutcomeTargetingError,
validateMechanicProgram,
type ClauseMutation,
type MechanicClause,
type MechanicEventKind,
type MechanicOutcome,
type MechanicOutcomeScope,
type MechanicOutcomeTargeting,
type MechanicProgram,
type MechanicResource,
type NumericExpression,
@@ -198,6 +200,44 @@ export function registeredEventFields(event: MechanicEventKind) {
return MECHANIC_EVENT_REGISTRY[event].fields ?? [];
}
export type MechanicTargetingMode =
| 'none'
| 'allies_within_radius'
| 'enemies_within_radius'
| 'event_target_explosion';
export function mechanicTargetingMode(
targeting: MechanicOutcomeTargeting | undefined,
): MechanicTargetingMode {
if (!targeting) return 'none';
if (targeting.kind === 'event_target_area') return 'event_target_explosion';
return targeting.population === 'allies'
? 'allies_within_radius'
: 'enemies_within_radius';
}
export function mechanicTargetingForMode(
mode: MechanicTargetingMode,
current?: MechanicOutcomeTargeting,
): MechanicOutcomeTargeting | undefined {
if (mode === 'none') return undefined;
const radiusMeters =
current?.radiusMeters ?? (mode === 'event_target_explosion' ? 5 : 20);
if (mode === 'event_target_explosion') {
return {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters,
};
}
return {
kind: 'within_radius',
population: mode === 'allies_within_radius' ? 'allies' : 'enemies',
radiusMeters,
};
}
export type StatePoolThresholdComparison = {
pool: string;
operator: PredicateComparisonOperator;
@@ -258,6 +298,7 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = {
'maxStacks',
'targeting',
'descriptionSlot',
'probabilityPercent',
],
phase: ['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active'],
kind: [
@@ -291,6 +332,25 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = {
continuous:
'Valid only for restoration. The value is the total restoration, delivered at a constant rate across durationSeconds. Each activation creates an independent stream; overlapping streams add and overheal is lost when it occurs.',
},
probabilitySemantics:
'probabilityPercent is an optional inclusive 0 through 100 percentage. In custom simulation, each eligible outcome rolls independently from the evaluation random stream. Maximum mode resolves every nonzero probability as successful.',
targeting: {
withinRadius: {
kind: 'within_radius',
population: ['allies', 'enemies'],
radiusMeters: 'positive finite number',
semantics:
'Legacy radius metadata. It preserves authored range but does not select or fan out runtime targets.',
},
eventTargetArea: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 'positive finite number',
semantics:
'Valid only for target-scoped direct damage in on_event or after_event phases on a weapon_hit clause. The triggering event must supply an authoritative event-target area sample. Every valid enemy at or inside the radius receives one equal damage emission, ordered by entity ID.',
},
},
} as const;
const phases = new Set<OutcomePhase>(
CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.phase,
@@ -366,11 +426,14 @@ function isReference(value: unknown): value is ValueReference {
Boolean(value.path.trim()) &&
(value.timing === undefined ||
value.timing === 'pre_event' ||
value.timing === 'current') &&
!(value.path === 'currentHealthPercent' && value.timing === 'pre_event')
value.timing === 'current')
);
case 'equipment':
return typeof value.path === 'string' && Boolean(value.path.trim());
case 'assigned_ability':
return (
value.path === 'active' || value.path === 'remainingDurationSeconds'
);
case 'attribute':
return (
typeof value.attributeId === 'string' &&
@@ -527,6 +590,17 @@ function isMutation(value: unknown): value is ClauseMutation {
isExpression(value.value))
);
}
if (value.kind === 'extend_duration') {
return value.target === 'assigned_ability' && isExpression(value.seconds);
}
if (value.kind === 'start_cooldown_on_max') {
return (
typeof value.stackPool === 'string' &&
Boolean(value.stackPool.trim()) &&
typeof value.cooldownPool === 'string' &&
Boolean(value.cooldownPool.trim())
);
}
return (
['add', 'set', 'consume', 'derive'].includes(value.kind) &&
typeof value.pool === 'string' &&
@@ -724,30 +798,21 @@ function parseOutcome(
) {
errors.push(`${path} description slot must be a positive integer.`);
}
if (value.targeting !== undefined) {
if (
!object(value.targeting) ||
value.targeting.kind !== 'within_radius' ||
!['allies', 'enemies'].includes(String(value.targeting.population)) ||
!finite(value.targeting.radiusMeters) ||
value.targeting.radiusMeters <= 0
) {
errors.push(`${path} has invalid radius targeting.`);
} else if (
value.targeting.population === 'allies' &&
value.scope !== 'allies' &&
value.scope !== 'self_and_allies'
) {
errors.push(
`${path} ally radius targeting requires allies or self-and-allies scope.`,
);
} else if (
value.targeting.population === 'enemies' &&
value.scope !== 'target'
) {
errors.push(`${path} enemy radius targeting requires target scope.`);
}
if (
value.probabilityPercent !== undefined &&
(!finite(value.probabilityPercent) ||
value.probabilityPercent < 0 ||
value.probabilityPercent > 100)
) {
errors.push(`${path} probability must be from 0 through 100 percent.`);
}
const targetingError = mechanicOutcomeTargetingError({
kind,
scope: value.scope as MechanicOutcomeScope,
phase: value.phase as OutcomePhase,
targeting: value.targeting,
});
if (targetingError) errors.push(`${path} ${targetingError}`);
if (!object(value.variants)) {
errors.push(`${path} variants must be an object.`);
return null;
@@ -828,6 +893,22 @@ function parseClauses(
if (candidate.weight !== undefined && !isExpression(candidate.weight)) {
errors.push(`${path} has an invalid weight expression.`);
}
if (
candidate.procChancePercent !== undefined &&
(!finite(candidate.procChancePercent) ||
candidate.procChancePercent < 0 ||
candidate.procChancePercent > 100)
) {
errors.push(`${path} proc chance must be from 0 through 100 percent.`);
}
if (
candidate.requiresAssignedAbilityActive !== undefined &&
typeof candidate.requiresAssignedAbilityActive !== 'boolean'
) {
errors.push(
`${path} assigned-ability activity requirement must be a boolean.`,
);
}
if (
candidate.cooldownSeconds !== undefined &&
(!finite(candidate.cooldownSeconds) || candidate.cooldownSeconds <= 0)
@@ -902,6 +983,15 @@ function programsForDraft(
? {}
: { cooldownSeconds: clause.cooldownSeconds }),
...(clause.weight === undefined ? {} : { weight: clause.weight }),
...(clause.procChancePercent === undefined
? {}
: { procChancePercent: clause.procChancePercent }),
...(clause.requiresAssignedAbilityActive === undefined
? {}
: {
requiresAssignedAbilityActive:
clause.requiresAssignedAbilityActive,
}),
mutations: clause.mutations,
outcomes: clause.outcomes.map(
({
@@ -200,6 +200,143 @@ describe('conditional-effect persistence adapter', () => {
});
});
it('preserves event-target area damage semantics on an outcome', () => {
const result = adaptConditionalEffectProgram(
{
rule: rule(),
statePools: [],
clauses: [
clause({
trigger_id: null,
event_kind: 'weapon_hit',
state_pool_id: null,
mutations_json: '[]',
}),
],
outcomes: [
outcome({
application_kind: 'direct_damage',
effect_scope: 'target',
stat_attribute_id: null,
application_phase: 'after_event',
state_pool_id: null,
targeting_json: JSON.stringify({
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
}),
}),
],
triggers: [],
},
{ name: 'Critical Explosion', variantKey: 'I' },
);
expect(result.errors).toEqual([]);
expect(result.program?.clauses[0].outcomes?.[0]).toMatchObject({
kind: 'direct_damage',
scope: 'target',
phase: 'after_event',
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
},
});
});
it('adapts assigned-ability activity predicates for kill and assist events', () => {
for (const eventKind of ['enemy_killed', 'assist'] as const) {
const result = adaptConditionalEffectProgram(
{
rule: rule(),
statePools: [],
clauses: [
clause({
trigger_id: null,
event_kind: eventKind,
parameters_json: '{}',
state_pool_id: null,
mutations_json: '[]',
conditions_json: JSON.stringify({
kind: 'compare',
left: {
source: 'event',
path: 'values.assignedAbilityActive',
},
operator: 'eq',
right: true,
}),
}),
],
outcomes: [outcome({ state_pool_id: null })],
triggers: [],
},
{ variantKey: 'I' },
);
expect(result).toMatchObject({ status: 'READY', errors: [] });
expect(result.program?.clauses[0]).toMatchObject({
when: eventKind,
if: {
kind: 'compare',
left: {
source: 'event',
path: 'values.assignedAbilityActive',
},
operator: 'eq',
right: true,
},
});
}
});
it('preserves outcome probability metadata', () => {
const result = adaptConditionalEffectProgram(
{
rule: rule(),
statePools: [],
clauses: [
clause({
trigger_id: null,
event_kind: 'shot_fired',
recipe_parameters_json: JSON.stringify({
procChancePercent: 10,
requiresAssignedAbilityActive: true,
}),
parameters_json: '{}',
state_pool_id: null,
mutations_json: '[]',
}),
],
outcomes: [
outcome({
state_pool_id: null,
application_kind: 'restore_ammo',
effect_scope: 'self',
stat_attribute_id: null,
resource_kind: 'ammo',
probability_percent: 29,
}),
],
triggers: [],
},
{ variantKey: 'I' },
);
expect(result).toMatchObject({ status: 'READY', errors: [] });
expect(result.program?.clauses[0].outcomes?.[0]).toMatchObject({
probabilityPercent: 29,
resource: 'ammo',
});
expect(result.program?.clauses[0]).toMatchObject({
procChancePercent: 10,
requiresAssignedAbilityActive: true,
});
});
it('loads continuous restoration delivery metadata', () => {
const result = adaptConditionalEffectProgram(
{
@@ -713,7 +850,7 @@ describe('conditional-effect persistence adapter', () => {
left: {
source: 'combat',
path: 'currentHealthPercent',
timing: 'current',
timing: 'pre_event',
},
operator: 'lt',
right: 25,
+55 -3
View File
@@ -54,6 +54,7 @@ export type ConditionalEffectClauseRow = {
event_kind?: DatabaseValue;
recipe_key?: DatabaseValue;
parameters_json: DatabaseValue;
recipe_parameters_json?: DatabaseValue;
conditions_json: DatabaseValue;
reducer_json: DatabaseValue;
weight_expression_json?: DatabaseValue;
@@ -85,6 +86,7 @@ export type ConditionalEffectOutcomeRow = {
delivery_mode?: DatabaseValue;
delivery_duration_seconds?: DatabaseValue;
targeting_json?: DatabaseValue;
probability_percent?: DatabaseValue;
value_scaling: DatabaseValue;
unit: DatabaseValue;
damage_basis: DatabaseValue;
@@ -205,14 +207,16 @@ function isValueReference(value: unknown) {
if (value.source === 'event' || value.source === 'equipment') {
return typeof value.path === 'string' && Boolean(value.path.trim());
}
if (value.source === 'assigned_ability') {
return value.path === 'active' || value.path === 'remainingDurationSeconds';
}
if (value.source === 'combat') {
return (
typeof value.path === 'string' &&
Boolean(value.path.trim()) &&
(value.timing === undefined ||
value.timing === 'pre_event' ||
value.timing === 'current') &&
!(value.path === 'currentHealthPercent' && value.timing === 'pre_event')
value.timing === 'current')
);
}
if (value.source === 'attribute') {
@@ -313,6 +317,19 @@ function isMutation(value: unknown): value is ClauseMutation {
isNumericExpression(value.value))
);
}
if (value.kind === 'extend_duration') {
return (
value.target === 'assigned_ability' && isNumericExpression(value.seconds)
);
}
if (value.kind === 'start_cooldown_on_max') {
return (
typeof value.stackPool === 'string' &&
Boolean(value.stackPool.trim()) &&
typeof value.cooldownPool === 'string' &&
Boolean(value.cooldownPool.trim())
);
}
return (
['add', 'set', 'consume', 'derive'].includes(value.kind) &&
typeof value.pool === 'string' &&
@@ -731,6 +748,17 @@ function parseOutcome(
if (delivery === 'instant' && durationSeconds !== null) {
errors.push(`${path} instant delivery cannot set a duration.`);
}
const probabilityPercent = number(row.probability_percent);
if (
row.probability_percent !== undefined &&
row.probability_percent !== null &&
row.probability_percent !== '' &&
(probabilityPercent === null ||
probabilityPercent < 0 ||
probabilityPercent > 100)
) {
errors.push(`${path} probability must be from 0 through 100 percent.`);
}
let targeting: MechanicOutcome['targeting'] | undefined;
if (
row.targeting_json !== null &&
@@ -766,6 +794,7 @@ function parseOutcome(
? { delivery, durationSeconds }
: {}),
...(targeting ? { targeting } : {}),
...(probabilityPercent === null ? {} : { probabilityPercent }),
...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id)
? { attributeId: text(row.stat_attribute_id) }
: {}),
@@ -903,7 +932,7 @@ export function adaptConditionalEffectProgram(
continue;
}
const parametersJson = parseJson(
row.parameters_json,
row.recipe_parameters_json ?? row.parameters_json,
`${path} parameters`,
errors,
);
@@ -976,6 +1005,25 @@ export function adaptConditionalEffectProgram(
}
}
const recipeKey = nullableText(row.recipe_key);
const procChancePercent = parametersJson.value.procChancePercent;
if (
procChancePercent !== undefined &&
(!finite(procChancePercent) ||
procChancePercent < 0 ||
procChancePercent > 100)
) {
errors.push(`${path} proc chance must be from 0 through 100 percent.`);
}
const requiresAssignedAbilityActive =
parametersJson.value.requiresAssignedAbilityActive;
if (
requiresAssignedAbilityActive !== undefined &&
typeof requiresAssignedAbilityActive !== 'boolean'
) {
errors.push(
`${path} assigned-ability activity requirement must be a boolean.`,
);
}
clauses.push({
id,
label: text(row.label) || text(row.clause_key) || undefined,
@@ -989,6 +1037,10 @@ export function adaptConditionalEffectProgram(
? {}
: { cooldownSeconds: number(row.cooldown_seconds) as number }),
...(weight === undefined ? {} : { weight }),
...(typeof procChancePercent === 'number' ? { procChancePercent } : {}),
...(typeof requiresAssignedAbilityActive === 'boolean'
? { requiresAssignedAbilityActive }
: {}),
mutations: mutationsJson.value,
...(clauseOutcomes.length ? { outcomes: clauseOutcomes } : {}),
});
+738 -18
View File
@@ -19,6 +19,276 @@ const custom = (
inputs,
});
describe('deterministic mechanic probabilities and assigned abilities', () => {
it('rolls outcome probability independently with a reproducible seed and preserves a refreshed window', () => {
const program: MechanicProgram = {
id: 'ammo-refill',
name: 'Ammo Refill',
pools: [
{
key: 'tactical-link-window',
label: 'Tactical Link window',
scope: 'agent',
initial: 0,
min: 0,
max: 1,
expiresAfterSeconds: 6,
},
],
clauses: [
{
id: 'start-window',
when: 'signature_ability_deployed',
reducer: { kind: 'none' },
mutations: [{ kind: 'set', pool: 'tactical-link-window', value: 1 }],
},
{
id: 'refresh-window',
when: 'enemy_killed',
reducer: { kind: 'none' },
mutations: [{ kind: 'set', pool: 'tactical-link-window', value: 1 }],
},
{
id: 'recover-ammo',
when: 'shot_fired',
if: {
kind: 'compare',
left: { source: 'pool', pool: 'tactical-link-window' },
operator: 'eq',
right: 1,
},
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
id: 'recover-one-ammo',
label: 'Recover one ammo',
phase: 'after_event',
kind: 'restoration',
scope: 'self',
basis: 'flat',
resource: 'ammo',
value: 1,
probabilityPercent: 29,
},
],
},
],
};
const events = [
{
kind: 'signature_ability_deployed' as const,
at: 0,
values: { assignedAbilityDurationSeconds: 6 },
},
{ kind: 'shot_fired' as const, at: 1 },
{ kind: 'shot_fired' as const, at: 2 },
{ kind: 'enemy_killed' as const, at: 5 },
{ kind: 'shot_fired' as const, at: 6 },
{ kind: 'shot_fired' as const, at: 11 },
];
const first = evaluateMechanicProgram(
program,
events,
{ mode: 'custom', encounterSeconds: 12, randomSeed: 1 },
{ maxHealth: 100, currentHealth: 100, maxAmmo: 20, currentAmmo: 5 },
);
const second = evaluateMechanicProgram(
program,
events,
{ mode: 'custom', encounterSeconds: 12, randomSeed: 1 },
{ maxHealth: 100, currentHealth: 100, maxAmmo: 20, currentAmmo: 5 },
);
expect(first.status).toBe('OK');
expect(first.randomSeed).toBe(1);
expect(first.outcomes.map((outcome) => outcome.at)).toEqual([2]);
expect(first.outcomes[0]).toMatchObject({
probabilityPercent: 29,
probabilityRoll: expect.any(Number),
appliedValue: 1,
});
expect(first.finalCombat?.currentAmmo).toBe(6);
expect(second.outcomes).toEqual(first.outcomes);
});
it('gates a proc before mutations, extends a live assigned ability, and starts cooldown on the eighth stack', () => {
const program: MechanicProgram = {
id: 'tactical-loop',
name: 'Tactical Loop',
pools: [
{
key: 'extension-stacks',
label: 'Extension stacks',
scope: 'agent',
initial: 7,
min: 0,
max: 8,
},
{
key: 'extension-cooldown',
label: 'Extension cooldown',
scope: 'agent',
initial: 0,
min: 0,
max: 1,
expiresAfterSeconds: 15,
},
],
clauses: [
{
id: 'extend-tactical-link',
when: 'skill_hit',
if: {
kind: 'compare',
left: { source: 'pool', pool: 'extension-cooldown' },
operator: 'eq',
right: 0,
},
requiresAssignedAbilityActive: true,
procChancePercent: 10,
reducer: { kind: 'none' },
mutations: [
{ kind: 'add', pool: 'extension-stacks', value: 1 },
{
kind: 'extend_duration',
target: 'assigned_ability',
seconds: 0.3,
},
{
kind: 'start_cooldown_on_max',
stackPool: 'extension-stacks',
cooldownPool: 'extension-cooldown',
},
],
},
],
};
const success = evaluateMechanicProgram(
program,
[
{
kind: 'signature_ability_deployed',
at: 0,
values: { assignedAbilityDurationSeconds: 6 },
},
{ kind: 'skill_hit', at: 5.9 },
{ kind: 'skill_hit', at: 6.1 },
],
{ mode: 'custom', encounterSeconds: 7, randomSeed: 7 },
);
const failed = evaluateMechanicProgram(
program,
[
{
kind: 'signature_ability_deployed',
at: 0,
values: { assignedAbilityDurationSeconds: 6 },
},
{ kind: 'skill_hit', at: 5.9 },
],
{ mode: 'custom', encounterSeconds: 7, randomSeed: 1 },
);
expect(success.status).toBe('OK');
expect(success.emissions).toHaveLength(1);
const extension = success.emissions[0].mutations.find(
(mutation) => mutation.kind === 'extend_duration',
);
expect(extension).toMatchObject({ kind: 'extend_duration' });
expect(extension?.before).toBeCloseTo(0.1);
expect(extension?.after).toBeCloseTo(0.4);
expect(success.emissions[0].mutations).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: 'start_cooldown_on_max',
pool: 'extension-cooldown',
after: 1,
}),
]),
);
expect(success.finalState).toEqual(
expect.arrayContaining([
expect.objectContaining({
pool: 'extension-stacks',
value: 8,
}),
expect.objectContaining({
pool: 'extension-cooldown',
value: 1,
expiresAt: 20.9,
}),
]),
);
expect(failed.emissions).toEqual([]);
});
it('does not extend an assigned ability at its exact expiry and rejects invalid chance data', () => {
const boundaryProgram: MechanicProgram = {
id: 'exact-expiry',
name: 'Exact expiry',
clauses: [
{
id: 'extend',
when: 'skill_hit',
requiresAssignedAbilityActive: true,
procChancePercent: 100,
reducer: { kind: 'none' },
mutations: [
{
kind: 'extend_duration',
target: 'assigned_ability',
seconds: 0.3,
},
],
},
],
};
expect(
evaluateMechanicProgram(
boundaryProgram,
[
{
kind: 'signature_ability_deployed',
at: 0,
values: { assignedAbilityDurationSeconds: 1 },
},
{ kind: 'skill_hit', at: 1 },
],
{ mode: 'custom', encounterSeconds: 2, randomSeed: 7 },
).emissions,
).toEqual([]);
const invalid = structuredClone(boundaryProgram);
invalid.clauses[0].procChancePercent = 101;
expect(validateMechanicProgram(invalid)).toMatchObject({
ok: false,
errors: [
expect.stringContaining('proc chance must be from 0 through 100'),
],
});
const invalidOutcome = structuredClone(boundaryProgram);
invalidOutcome.clauses[0].outcomes = [
{
id: 'bad-chance',
label: 'Bad chance',
phase: 'after_event',
kind: 'marker',
scope: 'self',
basis: 'flat',
value: 1,
probabilityPercent: -1,
},
];
expect(validateMechanicProgram(invalidOutcome)).toMatchObject({
ok: false,
errors: [
expect.stringContaining('probability must be from 0 through 100'),
],
});
});
});
describe('structured mechanic runtime', () => {
it('advertises the authoritative weapon-hit target and headshot fields', () => {
expect(MECHANIC_EVENT_REGISTRY.weapon_hit).toMatchObject({
@@ -32,6 +302,11 @@ describe('structured mechanic runtime', () => {
},
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' },
{ path: 'values.critical', label: 'Critical hit', type: 'boolean' },
{
path: 'values.aiming',
label: 'Aiming down sights',
type: 'boolean',
},
{
path: 'values.behindCover',
label: 'Agent behind cover',
@@ -305,6 +580,161 @@ describe('structured mechanic runtime', () => {
});
});
it('matches kills and assists only while the assigned ability is active', () => {
const weaponCriticalHitChance = 'weapon-critical-hit-chance';
const activeOutcome = {
id: 'tactical-link-critical-chance',
label: 'Weapon Critical Hit Chance',
phase: 'while_active' as const,
kind: 'attribute_modifier' as const,
scope: 'self' as const,
basis: 'flat' as const,
value: 10.2,
attributeId: weaponCriticalHitChance,
statePool: 'tactical-link-kill-bonus',
};
const activePredicate = {
kind: 'compare' as const,
left: {
source: 'event' as const,
path: 'values.assignedAbilityActive',
},
operator: 'eq' as const,
right: true,
};
const program: MechanicProgram = {
id: 'tactical-link-kill-bonus',
name: 'Tactical Link Kill Bonus',
pools: [
{
key: 'tactical-link-kill-bonus',
label: 'Tactical Link kill bonus',
scope: 'agent',
initial: 0,
min: 0,
max: 1,
expiresAfterSeconds: 10,
},
],
clauses: [
{
id: 'kill-during-tactical-link',
when: 'enemy_killed',
if: activePredicate,
reducer: { kind: 'none' },
mutations: [
{ kind: 'set', pool: 'tactical-link-kill-bonus', value: 1 },
],
outcomes: [activeOutcome],
},
{
id: 'assist-during-tactical-link',
when: 'assist',
if: activePredicate,
reducer: { kind: 'none' },
mutations: [
{ kind: 'set', pool: 'tactical-link-kill-bonus', value: 1 },
],
outcomes: [{ ...activeOutcome }],
},
],
};
expect(validateMechanicProgram(program)).toMatchObject({ ok: true });
const result = evaluateMechanicProgram(
program,
[
{
kind: 'enemy_killed',
at: 0,
values: { assignedAbilityActive: true },
},
{
kind: 'assist',
at: 4,
values: { assignedAbilityActive: false },
},
{ kind: 'enemy_killed', at: 6 },
{
kind: 'assist',
at: 10,
values: { assignedAbilityActive: true },
},
],
custom(19),
{
maxHealth: 1_000,
currentHealth: 1_000,
attributes: { [weaponCriticalHitChance]: 0 },
},
);
expect(result.status).toBe('OK');
expect(result.outcomes.map((outcome) => outcome.at)).toEqual([0, 10]);
expect(result.finalState).toContainEqual(
expect.objectContaining({
pool: 'tactical-link-kill-bonus',
value: 1,
expiresAt: 20,
}),
);
});
it('rejects invalid assigned-ability activity predicates and event values', () => {
const program: MechanicProgram = {
id: 'assigned-ability-activity',
name: 'Assigned ability activity',
clauses: [
{
id: 'kill',
when: 'enemy_killed',
if: {
kind: 'compare',
left: {
source: 'event',
path: 'values.assignedAbilityActive',
},
operator: 'eq',
right: true,
},
reducer: { kind: 'none' },
mutations: [],
},
],
};
const invalidPredicate = structuredClone(program);
invalidPredicate.clauses[0].if = {
kind: 'compare',
left: { source: 'event', path: 'values.assignedAbilityActive' },
operator: 'eq',
right: 'yes',
};
expect(validateMechanicProgram(invalidPredicate)).toMatchObject({
ok: false,
errors: [
expect.stringContaining('Assigned ability active with a boolean value'),
],
});
expect(
evaluateMechanicProgram(
program,
[
{
kind: 'enemy_killed',
at: 0,
values: { assignedAbilityActive: 'yes' },
} as unknown as MechanicEvent,
],
custom(1),
),
).toMatchObject({
status: 'INVALID',
errors: [
expect.stringContaining('Assigned ability active must be a boolean'),
],
});
});
it('executes imported talent trigger families as first-class events', () => {
const eventKinds: MechanicEventKind[] = [
'ally_downed',
@@ -2404,13 +2834,13 @@ describe('mechanic validation and pseudo-script', () => {
]);
});
it('rejects pre-event timing for the derived current Health percentage', () => {
const invalid: MechanicProgram = {
id: 'invalid-health-percent',
name: 'Invalid Health Percent',
it('evaluates pre-event timing for the derived current Health percentage', () => {
const program: MechanicProgram = {
id: 'pre-event-health-percent',
name: 'Pre-event Health Percent',
clauses: [
{
id: 'invalid',
id: 'full-health-before-event',
when: 'tick',
if: {
kind: 'compare',
@@ -2419,25 +2849,32 @@ describe('mechanic validation and pseudo-script', () => {
path: 'currentHealthPercent',
timing: 'pre_event',
},
operator: 'lt',
right: 50,
operator: 'gte',
right: 100,
},
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
id: 'matched',
label: 'Matched',
phase: 'on_event',
kind: 'marker',
scope: 'self',
basis: 'flat',
value: 1,
},
],
},
],
};
const result = validateMechanicProgram(invalid);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors).toEqual(
expect.arrayContaining([
expect.stringContaining(
'current Health percentage must use current timing',
),
]),
);
}
expect(validateMechanicProgram(program)).toMatchObject({ ok: true });
expect(
evaluateMechanicProgram(program, [{ kind: 'tick', at: 0 }], custom(1), {
maxHealth: 100,
currentHealth: 100,
}).outcomes.map((outcome) => outcome.outcomeId),
).toEqual(['matched']);
});
it('formats nested conditions and state operations deterministically', () => {
@@ -2550,4 +2987,287 @@ describe('mechanic validation and pseudo-script', () => {
expect(result.script).toContain('TO ALLIES WITHIN 40m');
expect(result.script).toContain('TO ENEMIES WITHIN 20m');
});
it('applies aimed critical-hit area damage once to each valid enemy at or inside the radius', () => {
const program: MechanicProgram = {
id: 'aimed-critical-explosion',
name: 'Aimed Critical Explosion',
clauses: [
{
id: 'aimed-critical-hit',
when: 'weapon_hit',
if: {
kind: 'all',
predicates: [
{
kind: 'compare',
left: { source: 'event', path: 'values.critical' },
operator: 'eq',
right: true,
},
{
kind: 'compare',
left: { source: 'event', path: 'values.aiming' },
operator: 'eq',
right: true,
},
],
},
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
id: 'explosion',
label: 'Explosion',
phase: 'after_event',
kind: 'direct_damage',
scope: 'target',
basis: 'flat',
value: {
op: 'multiply',
left: 20,
right: {
ref: { source: 'attribute', attributeId: 'firepower' },
},
},
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
},
probabilityPercent: 100,
},
],
},
],
};
const result = evaluateMechanicProgram(
program,
[
{
kind: 'weapon_hit',
at: 1,
targetId: 'enemy-b',
values: { critical: true, aiming: true },
areaTargetSample: {
center: 'event_target',
validTargets: [
{
entityId: 'enemy-c',
population: 'enemy',
distanceMeters: 5.001,
},
{ entityId: 'enemy-b', population: 'enemy', distanceMeters: 0 },
{ entityId: 'ally-a', population: 'ally', distanceMeters: 2 },
{ entityId: 'enemy-a', population: 'enemy', distanceMeters: 5 },
],
},
},
],
custom(5),
{ maxHealth: 1000, currentHealth: 1000, attributes: { firepower: 100 } },
);
expect(result.status).toBe('OK');
expect(result.outcomes.map((outcome) => outcome.targetId)).toEqual([
'enemy-a',
'enemy-b',
]);
expect(result.outcomes.map((outcome) => outcome.appliedValue)).toEqual([
2000, 2000,
]);
expect(
new Set(result.outcomes.map((outcome) => outcome.probabilityRoll)).size,
).toBe(1);
expect(result.outcomes[0].probabilityRoll).not.toBeNull();
expect(result.script).toContain(
'EXPLOSION CENTERED ON EVENT TARGET; APPLY TO VALID ENEMIES WITHIN 5m',
);
const maximum = evaluateMechanicProgram(
program,
[
{
kind: 'weapon_hit',
at: 1,
targetId: 'enemy-b',
values: { critical: true, aiming: true },
areaTargetSample: {
center: 'event_target',
validTargets: [
{ entityId: 'enemy-b', population: 'enemy', distanceMeters: 0 },
{ entityId: 'enemy-a', population: 'enemy', distanceMeters: 5 },
],
},
},
],
{ mode: 'maximum', encounterSeconds: 5 },
{ maxHealth: 1000, currentHealth: 1000, attributes: { firepower: 100 } },
);
expect(maximum.outcomes.map((outcome) => outcome.targetId)).toEqual([
'enemy-a',
'enemy-b',
]);
expect(
maximum.outcomes.every((outcome) => outcome.probabilityRoll === 0),
).toBe(true);
const skillProgram: MechanicProgram = {
...program,
id: 'skill-hit-explosion',
clauses: program.clauses.map((clause) => ({
...clause,
when: 'skill_hit',
if: undefined,
})),
};
expect(validateMechanicProgram(skillProgram)).toMatchObject({ ok: true });
expect(
evaluateMechanicProgram(
skillProgram,
[
{
kind: 'skill_hit',
at: 1,
targetId: 'enemy-b',
areaTargetSample: {
center: 'event_target',
validTargets: [
{ entityId: 'enemy-b', population: 'enemy', distanceMeters: 0 },
{ entityId: 'enemy-a', population: 'enemy', distanceMeters: 5 },
],
},
},
],
custom(5),
{
maxHealth: 1000,
currentHealth: 1000,
attributes: { firepower: 100 },
},
).outcomes.map((outcome) => outcome.targetId),
).toEqual(['enemy-a', 'enemy-b']);
});
it('requires authoritative area evidence only when an area outcome activates', () => {
const program: MechanicProgram = {
id: 'area-evidence',
name: 'Area Evidence',
clauses: [
{
id: 'critical-hit',
when: 'weapon_hit',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.critical' },
operator: 'eq',
right: true,
},
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
id: 'explosion',
label: 'Explosion',
phase: 'after_event',
kind: 'direct_damage',
scope: 'target',
basis: 'flat',
value: 1,
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
},
},
],
},
],
};
expect(
evaluateMechanicProgram(
program,
[
{
kind: 'weapon_hit',
at: 1,
targetId: 'enemy',
values: { critical: false },
},
],
custom(5),
),
).toMatchObject({ status: 'OK', outcomes: [] });
expect(
evaluateMechanicProgram(
program,
[
{
kind: 'weapon_hit',
at: 1,
targetId: 'enemy',
values: { critical: true },
},
],
custom(5),
),
).toMatchObject({
status: 'INVALID',
errors: [
expect.stringContaining('authoritative event-target area sample'),
],
});
});
it('rejects malformed and duplicate area-target samples', () => {
const result = evaluateMechanicProgram(
{ id: 'sample-validation', name: 'Sample Validation', clauses: [] },
[
{
kind: 'weapon_hit',
at: 1,
targetId: 'enemy',
areaTargetSample: {
center: 'event_target',
validTargets: [
{ entityId: 'enemy', population: 'enemy', distanceMeters: 0 },
{ entityId: 'enemy', population: 'enemy', distanceMeters: 1 },
],
},
},
],
custom(5),
);
expect(result).toMatchObject({
status: 'INVALID',
errors: [expect.stringContaining('duplicates entity "enemy"')],
});
const oversizedTargets = Array.from({ length: 257 }, (_, index) => ({
entityId: index === 0 ? 'enemy' : `enemy-${index}`,
population: 'enemy' as const,
distanceMeters: index === 0 ? 0 : 1,
}));
expect(
evaluateMechanicProgram(
{ id: 'sample-bound', name: 'Sample Bound', clauses: [] },
[
{
kind: 'weapon_hit',
at: 1,
targetId: 'enemy',
areaTargetSample: {
center: 'event_target',
validTargets: oversizedTargets,
},
},
],
custom(5),
),
).toMatchObject({
status: 'INVALID',
errors: [expect.stringContaining('cannot contain more than 256')],
});
});
});
File diff suppressed because it is too large Load Diff
+44 -24
View File
@@ -23,7 +23,9 @@
let coverUploading = $state(false);
let initialized = $state(false);
let lastContent = '';
let saveSequence = 0;
let saveTimer: number | undefined;
let saveInFlight = false;
let saveQueued = false;
const groupedTags = $derived.by(() => {
const groups: Record<string, ComposerTag[]> = {};
for (const tag of data.tags as ComposerTag[]) (groups[tag.group] ??= []).push(tag);
@@ -35,6 +37,40 @@
coverImageId, coverAlt, coverCaption, version };
}
function scheduleSave() {
if (saveTimer !== undefined) window.clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => {
saveTimer = undefined;
void saveDraft();
}, 900);
}
async function saveDraft() {
if (saveInFlight || !saveQueued) return;
saveInFlight = true;
saveQueued = false;
const draft = { ...payload(), tagIds: [...selectedTags] };
try {
const response = await fetch('/api/articles/draft', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(draft)
});
const result = await response.json();
if (response.status === 409) {
saveState = 'conflict';
return;
}
if (!response.ok) throw new Error(result.message || 'Draft save failed.');
version = result.version;
saveState = saveQueued ? 'saving' : 'saved';
} catch (error) {
saveState = 'error';
toast.error('Draft not saved', error instanceof Error ? error.message : 'Try again.');
} finally {
saveInFlight = false;
if (saveQueued && saveState !== 'conflict') scheduleSave();
}
}
$effect(() => {
const content = JSON.stringify({ title, deck, document, selectedTags, channel, coverImageId, coverAlt, coverCaption });
if (!initialized) {
@@ -44,29 +80,13 @@
}
if (content === lastContent) return;
lastContent = content;
const sequence = ++saveSequence;
saveQueued = true;
saveState = 'saving';
const timer = window.setTimeout(async () => {
try {
const response = await fetch('/api/articles/draft', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload())
});
const result = await response.json();
if (sequence !== saveSequence) return;
if (response.status === 409) {
saveState = 'conflict';
return;
}
if (!response.ok) throw new Error(result.message || 'Draft save failed.');
version = result.version;
saveState = 'saved';
} catch (error) {
if (sequence !== saveSequence) return;
saveState = 'error';
toast.error('Draft not saved', error instanceof Error ? error.message : 'Try again.');
}
}, 900);
return () => window.clearTimeout(timer);
if (!saveInFlight) scheduleSave();
return () => {
if (saveTimer !== undefined) window.clearTimeout(saveTimer);
saveTimer = undefined;
};
});
function toggleTag(id: string) {
@@ -115,7 +135,7 @@
<div class="article-publication-note"><p>{visibility === 'private' ? 'Only you can open this draft.' : visibility === 'unlisted' ? 'Anyone with the direct URL can read it.' : 'Listed in discovery and visible to everyone.'}</p>{#if data.article.pendingRevisionId}<strong>MODERATION REVIEW PENDING</strong>{/if}</div>
<PendingForm action="?/publish" class="article-publish-form">
<input type="hidden" name="articleId" value={data.article.id}/><input type="hidden" name="title" value={title}/><input type="hidden" name="deck" value={deck}/><input type="hidden" name="document" value={document}/><input type="hidden" name="tagIds" value={JSON.stringify(selectedTags)}/><input type="hidden" name="channel" value={channel}/><input type="hidden" name="coverImageId" value={coverImageId}/><input type="hidden" name="coverAlt" value={coverAlt}/><input type="hidden" name="coverCaption" value={coverCaption}/><input type="hidden" name="version" value={version}/><input type="hidden" name="visibility" value={visibility}/>
<button class="primary">{visibility === 'private' ? 'SAVE AS PRIVATE' : data.article.kind === 'guide' ? 'SUBMIT FOR PUBLICATION' : 'PUBLISH ARTICLE'}</button>
<button class="primary" disabled={saveState === 'saving' || coverUploading}>{visibility === 'private' ? 'SAVE AS PRIVATE' : data.article.kind === 'guide' ? 'SUBMIT FOR PUBLICATION' : 'PUBLISH ARTICLE'}</button>
</PendingForm>
</section>
+12 -2
View File
@@ -24,6 +24,16 @@
function link(mark: ArticleMark) {
return mark.type === 'link' ? mark.attrs?.href ?? '#' : '#';
}
function orderedListStart(node: ArticleNode) {
const start = Number(node.attrs?.start ?? 1);
return Number.isInteger(start) && start > 1 ? start : undefined;
}
function orderedListType(node: ArticleNode): 'a' | 'A' | 'i' | 'I' | undefined {
const type = node.attrs?.type;
return ['a', 'A', 'i', 'I'].includes(String(type)) ? type as 'a' | 'A' | 'i' | 'I' : undefined;
}
</script>
{#snippet renderText(text: string, marks: ArticleMark[], index = 0)}
@@ -32,7 +42,7 @@
{#if mark.type === 'bold'}<strong>{@render renderText(text, marks, index + 1)}</strong>
{:else if mark.type === 'italic'}<em>{@render renderText(text, marks, index + 1)}</em>
{:else if mark.type === 'code'}<code>{@render renderText(text, marks, index + 1)}</code>
{:else}<a href={link(mark)} rel="nofollow ugc noopener noreferrer">{@render renderText(text, marks, index + 1)}</a>{/if}
{:else}<a href={link(mark)} title={mark.attrs?.title} rel="nofollow ugc noopener noreferrer">{@render renderText(text, marks, index + 1)}</a>{/if}
{/if}
{/snippet}
@@ -46,7 +56,7 @@
{:else if node.type === 'heading' && Number(node.attrs?.level) === 2}<h2>{@render children(node)}</h2>
{:else if node.type === 'heading'}<h3>{@render children(node)}</h3>
{:else if node.type === 'bulletList'}<ul>{@render children(node)}</ul>
{:else if node.type === 'orderedList'}<ol>{@render children(node)}</ol>
{:else if node.type === 'orderedList'}<ol start={orderedListStart(node)} type={orderedListType(node)}>{@render children(node)}</ol>
{:else if node.type === 'listItem'}<li>{@render children(node)}</li>
{:else if node.type === 'blockquote'}<blockquote>{@render children(node)}</blockquote>
{:else if node.type === 'horizontalRule'}<hr/>
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import ArticleContent from '$lib/components/ArticleContent.svelte';
import ArticleDiscussion from '$lib/components/ArticleDiscussion.svelte';
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import { untrack } from 'svelte';
let { data, form = null } = $props();
@@ -25,7 +25,7 @@
<header class="article-reader-head">
<div class="article-reader-channel"><span>{data.article.channel ?? (data.article.kind === 'guide' ? 'COMMUNITY GUIDE' : 'NEWS')}</span>{#if data.article.featured}<b>FEATURED</b>{/if}{#if data.article.recommended}<b>RECOMMENDED</b>{/if}</div>
<h1>{data.article.title}</h1><p>{data.article.deck}</p>
<div class="article-reader-byline">{#if data.article.source}<div class="article-source-identity"><strong>{data.article.source.name}</strong><span>{publicationMeta}</span></div>{:else}<AgentIdentityLink size="compact" linked={Boolean(data.article.authorPublicSlug)} agent={{id:data.article.ownerId,displayName:data.article.author,publicSlug:data.article.authorPublicSlug,avatarUrl:`/avatars/user/${data.article.ownerId}`}} meta={publicationMeta}/>{/if}<div>{#if data.article.kind === 'guide' && data.article.owner}<a class="secondary" href={`/guides/${encodeURIComponent(data.article.slug)}/edit`}>EDIT GUIDE</a>{:else if data.article.kind === 'news' && data.canManageNews && !data.article.source}<a class="secondary" href={`/news/${encodeURIComponent(data.article.slug)}/edit`}>OPEN NEWSROOM</a>{/if}</div></div>
<div class="article-reader-byline">{#if data.article.source}<div class="article-source-identity"><strong>{data.article.source.name}</strong><span>{publicationMeta}</span></div>{:else}<AgentIdentityLink size="compact" linked={Boolean(data.article.authorPublicSlug)} agent={{id:data.article.ownerId,displayName:data.article.author,publicSlug:data.article.authorPublicSlug,avatarUrl:`/avatars/user/${data.article.ownerId}`}} meta={publicationMeta}/>{/if}<div>{#if data.article.kind === 'guide' && data.article.owner}<a class="secondary" href={`/guides/${encodeURIComponent(data.article.slug)}/edit`}>EDIT GUIDE</a>{:else if data.article.kind === 'news' && data.canManageNews && !data.article.source}<a class="secondary" href={`/news/${encodeURIComponent(data.article.slug)}/edit`}>Edit Article</a>{/if}</div></div>
{#if data.article.source}<aside class="article-source-notice"><div><span>OFFICIAL SOURCE</span><strong>Originally published by {data.article.source.name}</strong></div><a href={data.article.source.url} target="_blank" rel="noopener noreferrer">READ ON UBISOFT</a></aside>{/if}
{#if data.tags.length}<nav class="article-reader-tags" aria-label="Article tags">{#each data.tags as tag}<a href={`/${path}?tag=${encodeURIComponent(tag.slug)}`}>{tag.name}</a>{/each}</nav>{/if}
</header>
+4 -4
View File
@@ -5,7 +5,7 @@
items,
value = $bindable(''),
label,
placeholder = 'Search available items…',
placeholder = 'Search…',
disabledIds = [],
onchange
}: {
@@ -27,19 +27,19 @@
<span>{label}</span>
<button type="button" class="builder-picker-current" aria-expanded={open} onclick={() => open = !open}>
<ItemIcon src={selected?.iconUrl} alt=""/>
<b>{selected?.name ?? 'SELECT ITEM'}</b><small>{selected?.category ?? (selected ? 'EQUIPPED' : 'UNASSIGNED')}</small><em>{open ? '×' : '+'}</em>
<b>{selected?.name ?? 'SELECT ITEM'}</b><small>{selected?.category ?? (selected ? 'EQUIPPED' : 'UNASSIGNED')}</small><em>{open ? '×' : '+'}</em>
</button>
{#if open}
<div class="builder-picker-panel">
<input type="search" bind:value={query} {placeholder} aria-label={placeholder}/>
<div>
{#if selected}<button type="button" class="builder-picker-clear" onclick={() => choose('')}><i>×</i><span><b>CLEAR SLOT</b><small>REMOVE CURRENT SELECTION</small></span><em>CLEAR</em></button>{/if}
{#if selected}<button type="button" class="builder-picker-clear" onclick={() => choose('')}><i>×</i><span><b>CLEAR</b><small>REMOVE SELECTION</small></span><em>CLEAR</em></button>{/if}
{#each results as item}
<button type="button" class:selected={item.id === value} disabled={disabledIds.includes(item.id)} onclick={() => choose(item.id)}>
<ItemIcon src={item.iconUrl} alt=""/>
<span><b>{item.name}</b><small>{item.category ?? 'AVAILABLE'}</small></span><em>{item.id === value ? 'EQUIPPED' : disabledIds.includes(item.id) ? 'CONFLICT' : 'SELECT'}</em>
</button>
{:else}<p>NO MATCHING ITEMS</p>{/each}
{:else}<p>NO MATCHES</p>{/each}
</div>
</div>
{/if}
+24 -15
View File
@@ -24,6 +24,8 @@
} = $props();
let query = $state('');
let open = $state(false);
const selected = $derived(items.find((item) => item.id === value));
const visible = $derived(items.filter((item) =>
!query.trim() || `${item.name} ${item.category ?? ''}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())
));
@@ -31,26 +33,33 @@
function choose(id: string) {
value = id;
onchange?.(id);
if (searchable) {
open = false;
query = '';
}
}
</script>
<fieldset class="builder-radio-grid" class:icon-only={iconOnly} style={`--builder-radio-columns:${Math.max(1, columns)}`}>
<legend>{label}</legend>
{#if searchable}
{#if searchable}<button type="button" class="builder-radio-toggle" aria-expanded={open} onclick={() => open = !open}><ItemIcon src={selected?.iconUrl} alt=""/><span><b>{selected?.name ?? 'SELECT OPTION'}</b><small>{selected?.category ?? 'NOT CONFIGURED'}</small></span><em>{open ? 'CLOSE' : 'CHANGE'}</em></button>{/if}
{#if searchable && open}
<input class="builder-radio-search" type="search" bind:value={query} placeholder="SEARCH AVAILABLE OPTIONS" aria-label={`Search ${label.toLocaleLowerCase()}`}/>
{/if}
<div>
{#each visible as item}
<label title={item.name}>
<input type="radio" {name} value={item.id} checked={value === item.id} {required} aria-label={item.name} onchange={() => choose(item.id)}/>
<span>
<ItemIcon src={item.iconUrl} alt=""/>
<b>{item.name}</b>
{#if item.category}<small>{item.category}</small>{/if}
</span>
</label>
{:else}
<p>NO MATCHING OPTIONS</p>
{/each}
</div>
{#if !searchable || open}
<div>
{#each visible as item}
<label title={item.name}>
<input type="radio" {name} value={item.id} checked={value === item.id} {required} aria-label={item.name} onchange={() => choose(item.id)}/>
<span>
<ItemIcon src={item.iconUrl} alt=""/>
<b>{item.name}</b>
{#if item.category}<small>{item.category}</small>{/if}
</span>
</label>
{:else}
<p>NO MATCHING OPTIONS</p>
{/each}
</div>
{/if}
</fieldset>
+43 -5
View File
@@ -6,6 +6,7 @@
import type { GameIconName } from '$lib/game-icons';
import type { DatabaseItem } from '$lib/database/items';
import MarkdownContent from './MarkdownContent.svelte';
import WeaponModSlotIcon from './WeaponModSlotIcon.svelte';
let {
item,
@@ -16,9 +17,21 @@
hideDiscussionUtility?: boolean;
anchor?: boolean;
} = $props();
let protocolLevel = $state(1);
let talentUpgrades = $state(0);
const number = (value: number) => Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, '');
const valueWithUnit = (value: number, unit: string) => `${number(value)}${unit ? (unit === '%' ? '%' : ` ${unit}`) : ''}`;
const protocolPrimaryValue = () => item.protocolPreview
? item.protocolPreview.primaryBaseValue * (1 + (protocolLevel - 1) / 49)
: 0;
const talentDescription = () => item.protocolPreview?.talentDescription?.replace(/\{(\d+)\}/g, (placeholder, raw) => {
const effect = item.protocolPreview?.talentEffects[Number(raw) - 1];
return effect ? valueWithUnit(effect.baseValue * (1 + talentUpgrades / 5), effect.unit) : placeholder;
});
</script>
<article id={anchor ? `database-item-${item.id}` : undefined} class="database-item-card" class:talent-card={item.variant === 'talent'}>
<article id={anchor ? `database-item-${item.id}` : undefined} class="database-item-card" class:talent-card={item.variant === 'talent'} class:protocol-quality-standard={item.topBorderTone === 'standard'} class:protocol-quality-specialized={item.topBorderTone === 'specialized'} class:protocol-quality-superior={item.topBorderTone === 'superior'} class:protocol-quality-highend={item.topBorderTone === 'highend'}>
<header>
{#if item.renderUrl}<img class="database-item-render" src={item.renderUrl} alt="" loading="lazy"/>{/if}
<div class="database-item-icon">
@@ -31,13 +44,38 @@
{#if item.headerBadges?.length || item.headerBadge || item.headerMetaIcon}
<div class="database-header-status">
{#if item.headerMetaIcon}<GameIcon name={item.headerMetaIcon}/>{/if}
{#each item.headerBadges ?? [] as badge}<span class="database-header-badge">{badge}</span>{/each}
{#if item.headerBadge}<span class="database-header-badge">{item.headerBadge}</span>{/if}
{#each item.headerBadges ?? [] as badge}<span class="database-header-badge" class:exotic={badge === 'EXOTIC'}>{badge}</span>{/each}
{#if item.headerBadge}<span class="database-header-badge" class:protocol-badge-standard={item.headerBadgeTone === 'standard'} class:protocol-badge-specialized={item.headerBadgeTone === 'specialized'} class:protocol-badge-superior={item.headerBadgeTone === 'superior'} class:protocol-badge-highend={item.headerBadgeTone === 'highend'}>{item.headerBadge}</span>{/if}
</div>
{/if}
</header>
{#if item.badges?.length}<div class="database-item-badges">{#each item.badges as badge}<span class={badge.tone ?? ''}>{badge.label}</span>{/each}</div>{/if}
<DatabaseFacts facts={item.facts} layout={item.factLayout}/>
{#if item.protocolPreview}
<section class="database-protocol-preview">
<div class="database-protocol-primary"><span>{item.protocolPreview.primaryStat}</span><b>{Number.isFinite(protocolPrimaryValue()) ? valueWithUnit(protocolPrimaryValue(), item.protocolPreview.primaryUnit) : 'NOT CONFIGURED'}</b></div>
{#if item.protocolPreview.talentDescription}
<div class="database-protocol-talent"><p>{talentDescription()}</p></div>
{/if}
<div class="database-protocol-controls"><label class="database-protocol-level"><span>LVL {protocolLevel}</span><input type="range" min="1" max="50" step="1" bind:value={protocolLevel} aria-label={`${item.name} level`}/></label>{#if item.protocolPreview.talentDescription}<div class="database-protocol-diamonds" role="group" aria-label={`${item.name} talent upgrades`}>{#each [1, 2, 3, 4, 5] as upgrade}<button type="button" class:active={talentUpgrades >= upgrade} aria-label={`Set talent upgrades to ${upgrade}`} aria-pressed={talentUpgrades === upgrade} onclick={() => talentUpgrades = talentUpgrades === upgrade ? 0 : upgrade}>◆</button>{/each}</div>{/if}</div>
</section>
{:else}
<DatabaseFacts facts={item.facts} layout={item.factLayout}/>
{/if}
{#if item.weaponModSlots}
<div class="database-weapon-mod-slots">
<div><span>MOD SLOTS</span></div>
{#if item.weaponModSlots.length}
<ul aria-label={`${item.name} Weapon Mod slots`}>
{#each item.weaponModSlots as modSlot}
<li class:upgrade-slot={modSlot.unlockedAtTierOne} class:has-slot-icon={Boolean(modSlot.iconUrl)} title={`${modSlot.slot}${modSlot.unlockedAtTierOne ? ' - Unlocked when this weapon upgrades to Tier 1' : ''}`}>
{#if modSlot.iconUrl}<WeaponModSlotIcon slot={modSlot.slot} iconUrl={modSlot.iconUrl}/><span class="sr-only">{modSlot.slot}</span>{:else}<span>{modSlot.slot}</span>{/if}
{#if modSlot.unlockedAtTierOne}<small>T1+</small>{/if}
</li>
{/each}
</ul>
{:else}<p>MOD SLOT DATA NOT CONFIGURED</p>{/if}
</div>
{/if}
{#if item.lines?.length}
<div class="database-item-lines">{#each item.lines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>
{/if}
@@ -53,7 +91,7 @@
<section><span>TIER I</span><p>{item.description ?? 'Description is not available.'}</p></section>
<section><span>TIER II</span><p>{item.secondaryDescription ?? 'Description is not available.'}</p></section>
</div>
{:else}
{:else if !item.protocolPreview}
{#if item.description}<p class:data-description={item.descriptionStyle === 'data'}>{item.description}</p>{/if}
{#if item.secondaryDescription}<p class="secondary-description" class:data-description={item.descriptionStyle === 'data'}>{item.secondaryDescription}</p>{/if}
{/if}
+1 -1
View File
@@ -14,4 +14,4 @@
const resolvedAlt = $derived(src || !alt ? alt : `${alt} — icon awaiting contribution`);
</script>
<img src={src || MISSING_ITEM_ICON_URL} alt={resolvedAlt} class:missing-item-icon={!src} class={className}/>
<img src={src || MISSING_ITEM_ICON_URL} alt={resolvedAlt} class:missing-item-icon={!src} class={`item-icon ${className}`.trim()}/>
@@ -0,0 +1,125 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import {
MOD_QUALITIES,
MOD_SECONDARY_CAPACITY,
MOD_SECONDARY_QUALITIES,
type ModSecondaryRange
} from '$lib/mods/secondary-pool';
let {
kind,
pool,
stats,
error,
saved = false,
resultToken = ''
}: {
kind: 'Weapon Mod' | 'Gear Mod';
pool: ModSecondaryRange[];
stats: Array<{ id: string; name: string; unit: string }>;
error?: string;
saved?: boolean;
resultToken?: string;
} = $props();
const poolId = $derived(kind === 'Weapon Mod' ? 'weapon-mod-secondary-pool' : 'gear-mod-secondary-pool');
const action = '?/saveSecondaryPool';
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
const blankSecondary = (): ModSecondaryRange => ({
statId: '',
values: Object.fromEntries(
MOD_SECONDARY_QUALITIES.map((quality) => [quality, {
level1Min: 0,
level1Max: 0,
level20Min: 0,
level20Max: 0
}])
) as ModSecondaryRange['values']
});
let editing = $state(false);
let draft = $state<ModSecondaryRange[]>([]);
let handledResultToken = $state('');
function openEditor() {
draft = pool.length ? structuredClone(pool) : [blankSecondary(), blankSecondary()];
editing = true;
}
function addSecondary() {
if (draft.length < 50) draft = [...draft, blankSecondary()];
}
function removeSecondary(index: number) {
if (draft.length > 2) draft = draft.filter((_, itemIndex) => itemIndex !== index);
}
$effect(() => {
if (saved && resultToken && resultToken !== handledResultToken) {
handledResultToken = resultToken;
editing = false;
}
});
</script>
<section class="mod-secondary-pool-registry">
<div class="selection-head">
<div>
<span>SHARED {kind.toUpperCase()} SECONDARY POOL</span>
<small>ONE CANONICAL POOL FOR EVERY REGULAR {kind.toUpperCase()}</small>
</div>
<button type="button" class="row-action" onclick={openEditor}>EDIT SHARED POOL →</button>
</div>
<div class="mod-secondary-capacity">
{#each MOD_QUALITIES as quality}
<span><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><strong>{MOD_SECONDARY_CAPACITY[quality]}</strong><small>SECONDARY {MOD_SECONDARY_CAPACITY[quality] === 1 ? 'ROLL' : 'ROLLS'}</small></span>
{/each}
<span class="pool-total"><b>ATTRIBUTE POOL</b><strong>{pool.length}</strong><small>SHARED OPTIONS</small></span>
</div>
</section>
{#if editing}
<div class="dashboard-editor mod-secondary-pool-editor">
<div class="editor-title">
<div><p class="eyebrow">SHARED CONFIGURATION</p><h3>{kind.toUpperCase()} SECONDARY ATTRIBUTES</h3></div>
<button aria-label={`Close ${kind} secondary pool editor`} onclick={() => editing = false}>×</button>
</div>
<PendingForm {action}>
<input type="hidden" name="id" value={poolId}/>
<input type="hidden" name="name" value={`Shared ${kind} Secondary Pool`}/>
<input type="hidden" name="secondaryRanges" value={JSON.stringify(draft)}/>
<p class="console-note">Standard quality has no secondary roll. Specialized selects one option; Superior and High-end select two distinct options. Ranges scale linearly from Level 1 to Level 20.</p>
<div class="selection-head pool-editor-head">
<div><span>AVAILABLE ATTRIBUTES</span><small>SPECIALIZED · SUPERIOR · HIGH-END LEVEL ENDPOINTS</small></div>
<button type="button" class="row-action" onclick={addSecondary} disabled={draft.length >= 50}>ADD ATTRIBUTE +</button>
</div>
<div class="mod-secondary-pool-options">
{#each draft as range, index}
<div class="mod-secondary-pool-option">
<strong>{String(index + 1).padStart(2, '0')}</strong>
<SearchableAssignment
items={stats}
mode="single"
compact
subjectLabel="POSSIBLE ATTRIBUTE"
searchPlaceholder="Search attributes…"
excludedIds={draft.filter((_, itemIndex) => itemIndex !== index).map((item) => item.statId)}
bind:value={range.statId}
/>
{#each MOD_SECONDARY_QUALITIES as quality}
<div class="mod-secondary-quality-range">
<b class={qualityClass(quality)}>{quality.toUpperCase()}</b>
<label><span>LEVEL 1</span><div><input type="number" step="any" bind:value={range.values[quality].level1Min} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 1 minimum`} required/><i>–</i><input type="number" step="any" bind:value={range.values[quality].level1Max} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 1 maximum`} required/></div></label>
<label><span>LEVEL 20</span><div><input type="number" step="any" bind:value={range.values[quality].level20Min} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 20 minimum`} required/><i>–</i><input type="number" step="any" bind:value={range.values[quality].level20Max} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 20 maximum`} required/></div></label>
</div>
{/each}
<button type="button" aria-label={`Remove shared secondary attribute option ${index + 1}`} onclick={() => removeSecondary(index)} disabled={draft.length <= 2}>×</button>
</div>
{/each}
</div>
{#if error}<p class="error">{error}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => editing = false}>CANCEL</button><button class="primary">SAVE SHARED POOL →</button></div>
</PendingForm>
</div>
{/if}
@@ -29,8 +29,6 @@
gear_max: 'gearMax',
weapon_min: 'weaponMin',
weapon_max: 'weaponMax',
skill_chip_min: 'chipMin',
skill_chip_max: 'chipMax',
cap_value: 'capValue'
};
return proposed[names[key] ?? key];
@@ -46,6 +44,8 @@
);
const specializationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const skillChipRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const currentSharedSkillChipProfiles = $derived((Array.isArray(currentSnapshot) ? currentSnapshot : []) as any[]);
const proposedSharedSkillChipProfiles = $derived((jsonValue(proposed.profiles) ?? []) as any[]);
const skillChipFields = $derived([
{ label: 'NAME', current: skillChipRecord?.name, proposed: proposed.name },
{ label: 'SPECIALIZATION ABILITY', current: skillChipRecord?.specialization_ability_id, proposed: proposed.abilityId },
@@ -78,6 +78,7 @@
{ label: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId },
{ label: 'DURATION · SECONDS', current: specializationRecord?.duration_seconds, proposed: proposed.durationSeconds },
{ label: 'COOLDOWN · SECONDS', current: specializationRecord?.cooldown_seconds, proposed: proposed.cooldownSeconds },
{ label: 'SHARED SKILL CHIP ABILITY', current: (currentSnapshot?.skillChipLinks as any[])?.[0]?.ability_id === specializationRecord?.id ? (currentSnapshot?.skillChipLinks as any[])?.[0]?.compatible_ability_id : (currentSnapshot?.skillChipLinks as any[])?.[0]?.ability_id, proposed: proposed.skillChipCompatibleAbilityId },
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
]
: action === 'saveCombatEffect'
@@ -256,7 +257,7 @@
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('current', field[0]))}</strong></div>
{/each}
{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
{#each [['gear','GEAR'],['weapon','WEAPON']] as source}
<div class="preview-field range"><span>{source[1]} RANGE · OPTIONAL</span><div><strong>{shown(attributeValue('current', `${source[0]}_min`))}</strong><i>TO</i><strong>{shown(attributeValue('current', `${source[0]}_max`))}</strong></div></div>
{/each}
<div class="preview-field"><span>OPTIONAL CAP</span><strong>{shown(attributeValue('current', 'cap_value'))}</strong></div>
@@ -273,13 +274,24 @@
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('proposed', field[0]))}</strong></div>
{/each}
{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
{#each [['gear','GEAR'],['weapon','WEAPON']] as source}
<div class="preview-field range"><span>{source[1]} RANGE · OPTIONAL</span><div><strong>{shown(attributeValue('proposed', `${source[0]}_min`))}</strong><i>TO</i><strong>{shown(attributeValue('proposed', `${source[0]}_max`))}</strong></div></div>
{/each}
<div class="preview-field"><span>OPTIONAL CAP</span><strong>{shown(attributeValue('proposed', 'cap_value'))}</strong></div>
</div>
</article>
</div>
{:else if module === 'skillchips' && ['savePrimaryProfile','saveSecondaryPool'].includes(action)}
<div class="proposal-compare-grid">
<article class="proposal-editor-preview before">
<header><span>CURRENT LIVE · SHARED {action === 'savePrimaryProfile' ? 'PRIMARY PROFILE' : 'SECONDARY POOL'}</span><b>BEFORE</b></header>
<div class="proposal-structured-list">{#each currentSharedSkillChipProfiles as profile}<p>{profile.statId} · {Object.entries(profile.values ?? {}).map(([quality, range]: [string, any]) => `${quality.toUpperCase()} ${shown(range.min)}–${shown(range.max)}`).join(' · ')}</p>{:else}<p>SHARED PROFILE NOT CONFIGURED</p>{/each}</div>
</article>
<article class="proposal-editor-preview after">
<header><span>PROPOSED · SHARED {action === 'savePrimaryProfile' ? 'PRIMARY PROFILE' : 'SECONDARY POOL'}</span><b>AFTER</b></header>
<div class="proposal-structured-list">{#each proposedSharedSkillChipProfiles as profile}<p>{profile.statId} · {Object.entries(profile.values ?? {}).map(([quality, range]: [string, any]) => `${quality.toUpperCase()} ${shown(range.min)}–${shown(range.max)}`).join(' · ')}</p>{/each}</div>
</article>
</div>
{:else if module === 'skillchips' && action === 'save'}
<div class="proposal-compare-grid">
<article class="proposal-editor-preview before">
@@ -287,7 +299,7 @@
{#if skillChipRecord}
<div class="proposal-field-summary">
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.current)}</strong></div>{/each}
<div><span>ROLL POOLS</span><strong>{(currentSnapshot?.primaryOptions as any[])?.length ?? 0} PRIMARY · {(currentSnapshot?.secondaryOptions as any[])?.length ?? 0} SECONDARY</strong></div>
<div><span>3-PIECE TALENT</span><strong>{currentSnapshot?.talent ? 'ASSIGNED' : 'NOT ASSIGNED'}</strong></div>
</div>
<div class="proposal-structured-list">
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
@@ -301,7 +313,7 @@
<header><span>PROPOSED · SKILL CHIP</span><b>AFTER</b></header>
<div class="proposal-field-summary">
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.proposed)}</strong></div>{/each}
<div><span>ROLL POOLS</span><strong>{Array.isArray(proposed.primaryStatIds) ? proposed.primaryStatIds.length : Number(Boolean(proposed.primaryStatIds))} PRIMARY · {Array.isArray(proposed.secondaryStatIds) ? proposed.secondaryStatIds.length : Number(Boolean(proposed.secondaryStatIds))} SECONDARY</strong></div>
<div><span>3-PIECE TALENT</span><strong>{proposed.hasTalent === '1' ? 'ASSIGNED' : 'NOT ASSIGNED'}</strong></div>
</div>
<div class="proposal-structured-list">
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
@@ -0,0 +1,128 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import {
SKILL_CHIP_QUALITIES,
SKILL_CHIP_SECONDARY_CAPACITY,
SKILL_CHIP_SECONDARY_QUALITIES,
type SkillChipProfile
} from '$lib/skill-chips/profiles';
let {
primaryProfiles,
primaryProfileBase,
secondaryPool,
stats,
primaryError,
secondaryError,
primarySaved = false,
secondarySaved = false,
resultToken = ''
}: {
primaryProfiles: SkillChipProfile[];
primaryProfileBase: SkillChipProfile[];
secondaryPool: SkillChipProfile[];
stats: Array<{ id: string; name: string; unit: string }>;
primaryError?: string;
secondaryError?: string;
primarySaved?: boolean;
secondarySaved?: boolean;
resultToken?: string;
} = $props();
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
const blankValues = (qualities: readonly string[]) => Object.fromEntries(
qualities.map((quality) => [quality, { min: '', max: '' }])
);
const hydrated = (profile: SkillChipProfile, qualities: readonly string[]) => ({
statId: profile.statId,
values: Object.fromEntries(qualities.map((quality) => [quality, {
min: profile.values[quality]?.min ?? '',
max: profile.values[quality]?.max ?? ''
}]))
});
const blankSecondary = () => ({ statId: '', values: blankValues(SKILL_CHIP_SECONDARY_QUALITIES) });
let primaryEditing = $state(false);
let secondaryEditing = $state(false);
let primaryDraft = $state<any[]>([]);
let secondaryDraft = $state<any[]>([]);
let handledPrimaryToken = $state('');
let handledSecondaryToken = $state('');
function openPrimary() {
primaryDraft = primaryProfiles.map((profile) => hydrated(profile, SKILL_CHIP_QUALITIES));
secondaryEditing = false;
primaryEditing = true;
}
function openSecondary() {
const loaded: any[] = secondaryPool.map((profile) =>
hydrated(profile, SKILL_CHIP_SECONDARY_QUALITIES)
);
while (loaded.length < 2) loaded.push(blankSecondary());
secondaryDraft = loaded;
primaryEditing = false;
secondaryEditing = true;
}
$effect(() => {
if (primarySaved && resultToken && resultToken !== handledPrimaryToken) {
handledPrimaryToken = resultToken;
primaryEditing = false;
}
if (secondarySaved && resultToken && resultToken !== handledSecondaryToken) {
handledSecondaryToken = resultToken;
secondaryEditing = false;
}
});
</script>
<div class="skill-chip-shared-config">
<section class="mod-secondary-pool-registry">
<div class="selection-head"><div><span>SHARED PRIMARY PROFILE</span><small>FOUR ABILITY-SCOPED STATS · ONE RANGE PER QUALITY</small></div><button type="button" class="row-action" onclick={openPrimary}>EDIT PRIMARY RANGES →</button></div>
<div class="mod-secondary-capacity">
{#each SKILL_CHIP_QUALITIES as quality}<span><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><strong>{primaryProfiles.filter((profile) => profile.values[quality]).length}</strong><small>OF 4 CONFIGURED</small></span>{/each}
<span class="pool-total"><b>CALCULATION</b><strong>—</strong><small>NOT YET DEFINED</small></span>
</div>
</section>
<section class="mod-secondary-pool-registry">
<div class="selection-head"><div><span>SHARED SECONDARY POOL</span><small>ONE CANONICAL ATTRIBUTE POOL FOR EVERY SKILL CHIP</small></div><button type="button" class="row-action" onclick={openSecondary}>EDIT SHARED POOL →</button></div>
<div class="mod-secondary-capacity">
{#each SKILL_CHIP_QUALITIES as quality}<span><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><strong>{SKILL_CHIP_SECONDARY_CAPACITY[quality]}</strong><small>SECONDARY {SKILL_CHIP_SECONDARY_CAPACITY[quality] === 1 ? 'ROLL' : 'ROLLS'}</small></span>{/each}
<span class="pool-total"><b>ATTRIBUTE POOL</b><strong>{secondaryPool.length}</strong><small>SHARED OPTIONS</small></span>
</div>
</section>
</div>
{#if primaryEditing}
<div class="dashboard-editor skill-chip-profile-editor">
<div class="editor-title"><div><p class="eyebrow">SHARED CONFIGURATION</p><h3>SKILL CHIP PRIMARY RANGES</h3></div><button aria-label="Close primary profile editor" onclick={() => primaryEditing = false}>×</button></div>
<PendingForm action="?/savePrimaryProfile">
<input type="hidden" name="id" value="skill-chip-primary-profile"/><input type="hidden" name="name" value="Shared Skill Chip Primary Profile"/><input type="hidden" name="baseProfiles" value={JSON.stringify(primaryProfileBase)}/><input type="hidden" name="profiles" value={JSON.stringify(primaryDraft)}/>
<p class="console-note">Every Skill Chip rolls one of these four ability-scoped primary stats. Configure the direct minimum and maximum for each quality. Current combat calculations do not apply these values.</p>
<div class="skill-chip-profile-options fixed">
{#each primaryDraft as profile}
<div class="skill-chip-profile-option"><div><strong>{stats.find((item) => item.id === profile.statId)?.name ?? 'UNKNOWN ATTRIBUTE'}</strong><small>{stats.find((item) => item.id === profile.statId)?.unit || 'FLAT'} · ABILITY SCOPED</small></div>{#each SKILL_CHIP_QUALITIES as quality}<label><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><span><input type="number" min="0" step="any" bind:value={profile.values[quality].min} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? 'attribute'} minimum`} required/><i>–</i><input type="number" min="0" step="any" bind:value={profile.values[quality].max} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? 'attribute'} maximum`} required/></span></label>{/each}</div>
{/each}
</div>
{#if primaryError}<p class="error">{primaryError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => primaryEditing = false}>CANCEL</button><button class="primary">SAVE PRIMARY PROFILE →</button></div>
</PendingForm>
</div>
{/if}
{#if secondaryEditing}
<div class="dashboard-editor skill-chip-profile-editor">
<div class="editor-title"><div><p class="eyebrow">SHARED CONFIGURATION</p><h3>SKILL CHIP SECONDARY POOL</h3></div><button aria-label="Close secondary pool editor" onclick={() => secondaryEditing = false}>×</button></div>
<PendingForm action="?/saveSecondaryPool">
<input type="hidden" name="id" value="skill-chip-secondary-pool"/><input type="hidden" name="name" value="Shared Skill Chip Secondary Pool"/><input type="hidden" name="baseProfiles" value={JSON.stringify(secondaryPool)}/><input type="hidden" name="profiles" value={JSON.stringify(secondaryDraft)}/>
<p class="console-note">Standard has no secondary roll. Specialized selects one option; Superior and High-end select two distinct options. Skill Chips have no item level, so each quality uses one direct range.</p>
<div class="selection-head pool-editor-head"><div><span>AVAILABLE ATTRIBUTES</span><small>SPECIALIZED · SUPERIOR · HIGH-END RANGES</small></div><button type="button" class="row-action" onclick={() => secondaryDraft = [...secondaryDraft, blankSecondary()]} disabled={secondaryDraft.length >= 50}>ADD ATTRIBUTE +</button></div>
<div class="skill-chip-profile-options">
{#each secondaryDraft as profile, index}
<div class="skill-chip-profile-option selectable"><strong>{String(index + 1).padStart(2, '0')}</strong><SearchableAssignment items={stats} mode="single" compact subjectLabel="POSSIBLE ATTRIBUTE" searchPlaceholder="Search attributes…" excludedIds={secondaryDraft.filter((_, itemIndex) => itemIndex !== index).map((item) => item.statId)} bind:value={profile.statId}/>{#each SKILL_CHIP_SECONDARY_QUALITIES as quality}<label><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><span><input type="number" min="0" step="any" bind:value={profile.values[quality].min} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? `option ${index + 1}`} minimum`} required/><i>–</i><input type="number" min="0" step="any" bind:value={profile.values[quality].max} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? `option ${index + 1}`} maximum`} required/></span></label>{/each}<button type="button" aria-label={`Remove secondary option ${index + 1}`} onclick={() => secondaryDraft = secondaryDraft.filter((_, itemIndex) => itemIndex !== index)} disabled={secondaryDraft.length <= 2}>×</button></div>
{/each}
</div>
{#if secondaryError}<p class="error">{secondaryError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => secondaryEditing = false}>CANCEL</button><button class="primary">SAVE SHARED POOL →</button></div>
</PendingForm>
</div>
{/if}
@@ -0,0 +1,29 @@
<script lang="ts">
let {
slot,
iconUrl,
decorative = true,
size = 24
}: { slot: string; iconUrl?: string | null; decorative?: boolean; size?: number } = $props();
</script>
{#if iconUrl}
<img
class="weapon-mod-slot-icon"
src={iconUrl}
alt={decorative ? '' : `${slot} Weapon Mod slot`}
loading="lazy"
width={size}
height={size}
style={`--weapon-mod-slot-icon-size: ${size}px`}
/>
{/if}
<style>
img {
width: var(--weapon-mod-slot-icon-size);
height: var(--weapon-mod-slot-icon-size);
flex: 0 0 var(--weapon-mod-slot-icon-size);
object-fit: contain;
}
</style>
@@ -0,0 +1,212 @@
<script lang="ts">
import IconUploadField from '$lib/components/IconUploadField.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import WeaponModSlotIcon from '$lib/components/WeaponModSlotIcon.svelte';
type SlotIcon = {
id: string;
name: string;
iconUrl: string;
updatedAt: string;
};
let {
slots,
error = '',
saved = false,
resultToken = ''
}: {
slots: SlotIcon[];
error?: string;
saved?: boolean;
resultToken?: string;
} = $props();
let editing = $state<SlotIcon | null>(null);
let handledResultToken = $state('');
$effect(() => {
if (saved && resultToken && resultToken !== handledResultToken) {
handledResultToken = resultToken;
editing = null;
}
});
</script>
<section class="slot-icon-registry" aria-labelledby="slot-icon-registry-title">
<div class="selection-head">
<div>
<span id="slot-icon-registry-title">SHARED MOD SLOT ICONS</span>
<small>ONE CANONICAL ICON FOR EACH WEAPON MOD SLOT</small>
</div>
<b>{slots.every((slot) => slot.iconUrl) ? 'READY' : 'CONFIG REQUIRED'}</b>
</div>
<div class="slot-icon-grid">
{#each slots as slot}
<button
type="button"
class:configured={Boolean(slot.iconUrl)}
aria-pressed={editing?.id === slot.id}
onclick={() => editing = slot}
>
<span class="slot-icon-preview">
{#if slot.iconUrl}
<WeaponModSlotIcon slot={slot.name} iconUrl={slot.iconUrl}/>
{:else}
<i aria-hidden="true">?</i>
{/if}
</span>
<span><strong>{slot.name}</strong><small>{slot.iconUrl ? 'ICON REGISTERED' : 'ICON REQUIRED'}</small></span>
<b>EDIT</b>
</button>
{/each}
</div>
</section>
{#if editing}
<section class="dashboard-editor slot-icon-editor" aria-labelledby="slot-icon-editor-title">
<div class="editor-title">
<div><p class="eyebrow">SHARED MOD SLOT ARTWORK</p><h3 id="slot-icon-editor-title">{editing.name.toUpperCase()}</h3></div>
<button aria-label="Close slot icon editor" onclick={() => editing = null}>×</button>
</div>
<PendingForm action="?/saveSlotIcon" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="name" value={`${editing.name} Slot`}/>
<input type="hidden" name="slotType" value={editing.name}/>
<input type="hidden" name="baseUpdatedAt" value={editing.updatedAt}/>
<div class="slot-icon-editor-body">
<div class="slot-icon-editor-identity">
<span class="slot-icon-preview large">
{#if editing.iconUrl}
<WeaponModSlotIcon slot={editing.name} iconUrl={editing.iconUrl} size={56}/>
{:else}
<i aria-hidden="true">?</i>
{/if}
</span>
<div><small>CANONICAL SLOT</small><strong>{editing.name}</strong><p>Weapon cards use this artwork anywhere the slot is available.</p></div>
</div>
<IconUploadField currentUrl={editing.iconUrl} label={`${editing.name.toUpperCase()} SLOT ICON`} resetToken={`${editing.id}:${editing.updatedAt}`}/>
</div>
{#if error}<p class="error" role="alert">{error}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE SLOT ICON →</button></div>
</PendingForm>
</section>
{/if}
<style>
.slot-icon-registry {
margin: 10px 0;
padding: 10px;
border: 1px solid var(--shd-border);
border-left: 2px solid var(--shd-info);
background: #0d1011;
}
.slot-icon-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 7px;
margin-top: 8px;
}
.slot-icon-grid > button {
min-height: 62px;
display: grid;
grid-template-columns: 38px minmax(0, 1fr) auto;
align-items: center;
gap: 9px;
padding: 8px;
border: 1px solid var(--shd-border);
background: #101415;
color: var(--shd-text);
text-align: left;
cursor: pointer;
}
.slot-icon-grid > button:hover,
.slot-icon-grid > button:focus-visible,
.slot-icon-grid > button[aria-pressed='true'] {
border-color: var(--shd-orange);
background: #191712;
}
.slot-icon-grid > button:focus-visible {
outline: 2px solid var(--shd-orange);
outline-offset: 2px;
}
.slot-icon-grid > button > span:nth-child(2) {
min-width: 0;
display: grid;
gap: 3px;
}
.slot-icon-grid strong,
.slot-icon-grid small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.slot-icon-grid strong {
font-size: var(--shd-type-label);
letter-spacing: .04em;
}
.slot-icon-grid small,
.slot-icon-grid > button > b {
color: var(--shd-text-muted);
font-size: var(--shd-type-micro);
letter-spacing: .07em;
}
.slot-icon-grid > button.configured small,
.slot-icon-grid > button.configured > b {
color: var(--shd-info);
}
.slot-icon-preview {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border: 1px solid #313839;
background: #090b0c;
}
.slot-icon-preview i {
color: #586164;
font-size: 20px;
font-style: normal;
font-weight: 900;
}
.slot-icon-editor {
border-top-color: var(--shd-info);
}
.slot-icon-editor-body {
display: grid;
grid-template-columns: minmax(260px, .7fr) minmax(360px, 1.3fr);
gap: 12px;
}
.slot-icon-editor-identity {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid var(--shd-border);
background: #0d1011;
}
.slot-icon-preview.large {
width: 76px;
height: 76px;
flex: 0 0 76px;
}
.slot-icon-editor-identity > div {
display: grid;
gap: 5px;
}
.slot-icon-editor-identity small,
.slot-icon-editor-identity p {
color: var(--shd-text-muted);
font-size: var(--shd-type-telemetry);
}
.slot-icon-editor-identity p {
margin: 0;
}
@media (max-width: 900px) {
.slot-icon-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.slot-icon-editor-body { grid-template-columns: 1fr; }
}
@media (max-width: 480px) {
.slot-icon-grid { grid-template-columns: 1fr; }
}
</style>
+31 -31
View File
@@ -37,20 +37,20 @@
&& Math.floor(Number(active.split(':')[1]) / 3) === Number(cell.id.split(':')[1]));
const issueLabel = (issue: string) => {
if (/not configured|required/i.test(issue)) return 'ACTION REQUIRED';
if (/incomplete/i.test(issue)) return 'DATA INCOMPLETE';
return 'CHECK CONFIGURATION';
if (/not configured|required|select|choose/i.test(issue)) return 'REQUIRED';
if (/incomplete/i.test(issue)) return 'INCOMPLETE';
return 'CHECK';
};
</script>
<section class="loadout-matrix" aria-label="Loadout matrix">
<header>
<span>LOADOUT MATRIX</span>
<b class:ready={configured}>{configured ? 'COMBAT READY' : 'CONFIGURATION INCOMPLETE'}</b>
<span>LOADOUT</span>
<b class:ready={configured}>{configured ? 'COMPLETE' : 'INCOMPLETE'}</b>
</header>
<div class="matrix-section doctrine-section">
<small class="section-label">DOCTRINE · ROOT CHOICE</small>
<small class="section-label">SPECIALIZATION FOCUS</small>
<button
type="button"
class="doctrine-cell"
@@ -60,27 +60,15 @@
onclick={() => onselect(doctrine.id)}
>
<ItemIcon src={doctrine.iconUrl} alt="" className="matrix-icon" />
<span><small>{doctrine.label}</small><strong>{doctrine.name}</strong><em>{doctrine.summary}</em>{#if doctrine.issue}<mark title={doctrine.issue} aria-hidden="true">{issueLabel(doctrine.issue)}</mark><span class="sr-only">{doctrine.issue}</span>{/if}</span>
<span><small>{doctrine.label}</small><strong>{doctrine.name}</strong><em class:visible={selected(doctrine)}>{doctrine.summary}</em>{#if doctrine.issue}<mark title={doctrine.issue} aria-hidden="true">{issueLabel(doctrine.issue)}</mark><span class="sr-only">{doctrine.issue}</span>{/if}</span>
<i aria-hidden="true"></i>
</button>
<div class="supply-line"><span>SUPPLIES 3 ABILITIES</span></div>
<div class="supply-line"><span>ABILITIES</span></div>
<div class="ability-cells">
{#each abilities as cell}
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
</button>
{/each}
</div>
</div>
<div class="matrix-section">
<small class="section-label">WEAPONS · PRIMARY + SECONDARY</small>
<div class="equipment-cells">
{#each weapons as cell}
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<span><small>{cell.label}</small><strong>{cell.name}</strong><em class:visible={selected(cell)}>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<i aria-hidden="true"></i>
</button>
{/each}
@@ -88,12 +76,25 @@
</div>
<div class="matrix-section">
<small class="section-label">GEAR · 6 POSITIONS</small>
<small class="section-label">WEAPONS</small>
<div class="equipment-cells">
{#each weapons as cell}
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em class:visible={selected(cell)}>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<i aria-hidden="true"></i>
</button>
{/each}
</div>
</div>
<div class="matrix-section">
<small class="section-label">GEAR</small>
<div class="equipment-cells gear-cells">
{#each gear as cell}
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<span><small>{cell.label}</small><strong>{cell.name}</strong><em class:visible={selected(cell)}>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<i aria-hidden="true"></i>
</button>
{/each}
@@ -101,10 +102,10 @@
</div>
<div class="matrix-section protocol-section">
<small class="section-label">AGENT SYSTEM · ONE OS PROTOCOL</small>
<small class="section-label">OS PROTOCOL</small>
<button type="button" class:active={selected(protocol)} class:needs-attention={Boolean(protocol.issue)} aria-pressed={selected(protocol)} onclick={() => onselect(protocol.id)}>
<ItemIcon src={protocol.iconUrl} alt="" className="matrix-icon" />
<span><small>{protocol.label}</small><strong>{protocol.name}</strong><em>{protocol.summary}</em>{#if protocol.issue}<mark title={protocol.issue} aria-hidden="true">{issueLabel(protocol.issue)}</mark><span class="sr-only">{protocol.issue}</span>{/if}</span>
<span><small>{protocol.label}</small><strong>{protocol.name}</strong><em class:visible={selected(protocol)}>{protocol.summary}</em>{#if protocol.issue}<mark title={protocol.issue} aria-hidden="true">{issueLabel(protocol.issue)}</mark><span class="sr-only">{protocol.issue}</span>{/if}</span>
<i aria-hidden="true"></i>
</button>
</div>
@@ -112,21 +113,20 @@
<style>
.loadout-matrix{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}
.loadout-matrix>header{min-height:40px;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 10px;border-bottom:1px solid var(--shd-border);background:#0c0f10;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.08em}
.loadout-matrix>header{height:auto;min-height:40px;position:static;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 10px;border-bottom:1px solid var(--shd-border);background:#0c0f10;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.08em}
.loadout-matrix>header b{color:var(--shd-warning);font-size:9px;text-align:right}.loadout-matrix>header b.ready{color:var(--shd-success)}
.matrix-section{padding:5px;border-bottom:1px solid var(--shd-border)}.matrix-section:last-child{border-bottom:0}
.section-label{display:block;padding:2px 3px 5px;color:var(--shd-text-dim);font-size:9px;font-weight:900;letter-spacing:.09em}
.doctrine-cell,.equipment-cells button,.ability-cells button,.protocol-section>button{min-width:0;display:grid;grid-template-columns:34px minmax(0,1fr) 3px;align-items:center;gap:7px;padding:7px;border:1px solid var(--shd-border-soft);background:var(--shd-builder-canvas);color:var(--shd-text);text-align:left}
.doctrine-cell{width:100%;min-height:76px;grid-template-columns:42px minmax(0,1fr) 3px}.doctrine-cell :global(.matrix-icon){width:40px;height:40px}
:global(.matrix-icon){width:30px;height:30px;object-fit:contain}
button>span{min-width:0;display:grid;gap:2px}button small,button em{overflow:hidden;color:var(--shd-text-dim);font-size:9px;font-style:normal;letter-spacing:.04em;text-overflow:ellipsis;white-space:nowrap}button strong{overflow:hidden;font-size:11px;text-overflow:ellipsis;white-space:nowrap}button>i{align-self:stretch;background:var(--quality-high-end)}
button>span{min-width:0;display:grid;gap:2px}button small,button em{overflow:hidden;color:var(--shd-text-dim);font-size:9px;font-style:normal;letter-spacing:.04em;text-overflow:ellipsis;white-space:nowrap}button em{display:none}button em.visible{display:block}button strong{overflow:hidden;font-size:11px;text-overflow:ellipsis;white-space:nowrap}button>i{align-self:stretch;background:var(--quality-high-end)}
button:hover,button:focus-visible{border-color:var(--shd-border-strong);background:var(--shd-surface-active);outline:none}button.active{border-color:var(--shd-builder-selection-border);background:var(--shd-builder-selection);box-shadow:inset 2px 0 var(--shd-orange)}
button.needs-attention{border-bottom-color:color-mix(in srgb,var(--shd-danger) 68%,var(--shd-border-soft))}button.needs-attention>i{background:var(--shd-danger)}button mark{width:fit-content;max-width:100%;overflow:hidden;margin-top:2px;padding:2px 4px;border:1px solid color-mix(in srgb,var(--shd-danger) 58%,transparent);background:#1d1212;color:var(--shd-danger);font-size:8px;font-weight:900;letter-spacing:.07em;line-height:1.2;text-overflow:ellipsis;text-transform:uppercase;white-space:nowrap}
.supply-line{height:22px;display:grid;place-items:center;background:linear-gradient(90deg,transparent 49.7%,var(--shd-orange) 49.7% 50.3%,transparent 50.3%)}.supply-line span{padding:0 6px;background:var(--shd-surface);color:var(--shd-orange);font-size:8px;font-weight:900;letter-spacing:.08em}
.ability-cells{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:3px}.ability-cells button{min-height:68px;grid-template-columns:25px minmax(0,1fr);padding:6px}.ability-cells :global(.matrix-icon){width:24px;height:24px}
.ability-cells{display:grid;grid-template-columns:1fr;gap:3px}.ability-cells button{min-height:48px;grid-template-columns:25px minmax(0,1fr) 3px;padding:6px}.ability-cells :global(.matrix-icon){width:24px;height:24px}
.equipment-cells{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:3px}.equipment-cells button{min-height:69px}.protocol-section>button{width:100%;min-height:68px}
button:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px}
@media(max-width:1240px){.ability-cells{grid-template-columns:1fr}.ability-cells button{min-height:48px}}
@media(max-width:800px){.ability-cells{grid-template-columns:repeat(3,minmax(0,1fr))}}
@media(max-width:520px){.ability-cells{grid-template-columns:1fr}.equipment-cells{grid-template-columns:1fr 1fr}}
@media(max-width:800px) and (min-width:651px){.ability-cells{grid-template-columns:repeat(3,minmax(0,1fr))}.ability-cells button{min-height:68px}}
@media(max-width:520px){.ability-cells{grid-template-columns:1fr}.equipment-cells{grid-template-columns:1fr 1fr}.gear-cells{grid-template-columns:1fr}}
</style>
@@ -37,10 +37,9 @@
<section class:page={layout === 'page'} class="loadout-known-stats" aria-labelledby={headingId}>
<div class="summary-head">
<div>
<p class="eyebrow"><span></span> KNOWN EQUIPMENT TOTALS</p>
<p class="eyebrow"><span></span> KNOWN STATS</p>
<h2 id={headingId}>Agent stats</h2>
</div>
<b>{summary.stats.length}</b>
</div>
<div class="summary-core">
{#each ['Firepower', 'Toughness', 'Engineering'] as name}
@@ -53,49 +52,57 @@
</div>
<div class="summary-scroll">
<div class="summary-section">
<header><span>COMBINED ATTRIBUTES</span><b>{summary.stats.length}</b></header>
<header><span>ATTRIBUTES</span><b>{summary.stats.length}</b></header>
{#each summary.stats as stat}
<div class="summary-stat">
<span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span>
<b>{unitValue(stat.value, stat.id)}</b>
</div>
{:else}
<p>Equip and configure items to calculate their known Attributes.</p>
<p>Equip items to see known Attributes.</p>
{/each}
</div>
<div class="summary-section">
<header><span>ACTIVE SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>
<header><span>SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>
{#each summary.activeBonuses as bonus}
<div class="summary-bonus">
<span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span>
<em>+{unitValue(bonus.value, bonus.statId)} {attribute(bonus.statId)?.name}</em>
</div>
{:else}
<p>Matching Brand Sets and Skill Chips activate bonuses here.</p>
<p>No active bonuses.</p>
{/each}
</div>
<div class="summary-section">
<header><span>EQUIPPED TALENTS</span><b>{summary.equippedTalents.length}</b></header>
<header><span>TALENTS</span><b>{summary.equippedTalents.length}</b></header>
{#each summary.equippedTalents as equipped}
<div class="summary-talent">
<span>
<b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b>
<small>{equipped.source} · CONDITIONAL EFFECT NOT INCLUDED</small>
<em>{equipped.description}</em>
<details open={layout === 'page'}>
<summary><b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b><small>{equipped.source}{equipped.tierLabel ? ` · ${equipped.tierLabel}` : ''}</small></summary>
<p>{equipped.description}</p>
{#if equipped.id && talentCooldown(equipped.id)}<i class="talent-cooldown">{talentCooldown(equipped.id)}</i>{/if}
</span>
</details>
</div>
{:else}
<p>Selected equipment Talents appear here.</p>
<p>No Talents equipped.</p>
{/each}
</div>
</div>
<p class="known-stats-note">Totals reflect this saved loadout and the current canonical item definitions. They include fixed values, rolls, inherited profiles, enabled augments, set bonuses, Protocol effects, and static Specialization rank effects. Conditional Talent outcomes are reserved for the advanced combat model.</p>
<p class="known-stats-note">Known values only. Advanced effects are planned.</p>
</section>
<style>
.loadout-known-stats{background:var(--shd-surface)}
.summary-talent{grid-template-columns:1fr;cursor:default}
.summary-talent details,.summary-talent summary{min-width:0}
.summary-talent summary{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;align-items:center;cursor:pointer;list-style:none}
.summary-talent summary::-webkit-details-marker{display:none}
.summary-talent summary:after{content:'+';grid-column:2;grid-row:1/3;color:var(--shd-orange);font-size:14px}
.summary-talent details[open] summary:after{content:'-'}
.summary-talent summary b,.summary-talent summary small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.summary-talent summary small{grid-column:1;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}
.summary-talent details>p{margin:8px 0 0;color:var(--shd-text-muted);font:var(--shd-type-telemetry)/1.45 var(--shd-font-editorial)}
.summary-talent summary:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:3px}
.known-stats-note{margin:0;padding:11px;border-top:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.45}
.page{border:1px solid var(--shd-border);border-top:2px solid var(--shd-success)}
.page .summary-scroll{max-height:none;display:grid;grid-template-columns:repeat(3,minmax(0,1fr))}
@@ -10,20 +10,20 @@
} = $props();
const capabilities = [
{ label: 'WEAPON OUTPUT', detail: 'Hit values, burst DPS, sustained DPS, and reload-aware firing cycles' },
{ label: 'SKILL SIMULATION', detail: 'Ability cadence, target counts, critical hits, and multi-shot behavior' },
{ label: 'ENCOUNTER MODEL', detail: 'Duration, accuracy, movement, target flow, rotations, and downtime' },
{ label: 'CONDITIONAL EFFECTS', detail: 'Talent triggers, stack buildup, cooldowns, uptime, and resource changes' }
{ label: 'WEAPON OUTPUT', detail: 'Hits and firing cycles' },
{ label: 'SKILL SIMULATION', detail: 'Timing and target caps' },
{ label: 'ENCOUNTER MODEL', detail: 'Duration and rotations' },
{ label: 'CONDITIONAL EFFECTS', detail: 'Triggers and uptime' }
];
</script>
<section class:compact={variant === 'compact'} class="planned-combat-model" aria-labelledby={headingId}>
<header>
<div><span>ADVANCED COMBAT MODEL</span>{#if headingLevel === 'h2'}<h2 id={headingId}>DPS calculations</h2>{:else}<h3 id={headingId}>DPS calculations</h3>{/if}</div>
<b>COMING SOON</b>
<div><span>COMBAT MODEL</span>{#if headingLevel === 'h2'}<h2 id={headingId}>DPS calculations</h2>{:else}<h3 id={headingId}>DPS calculations</h3>{/if}</div>
<b>PLANNED</b>
</header>
{#if variant === 'full'}
<p>Known equipment totals remain available now. Advanced damage projections will return after their calculation rules and scenario controls are ready for player use.</p>
<p>Known stats are live. Damage projections are planned.</p>
{/if}
<div class="planned-capabilities" aria-label="Planned combat-model capabilities">
{#each capabilities as capability}
@@ -0,0 +1,61 @@
<script lang="ts">
import ItemIcon from '$lib/components/ItemIcon.svelte';
import type { TalentTier } from '$lib/builder/loadout';
let {
label = 'TALENT TIER',
name,
talent,
value = $bindable(1),
onchange
}: {
label?: string;
name: string;
talent: { name: string; iconUrl?: string; tiers?: Array<{ tier: string }> };
value?: TalentTier;
onchange?: (tier: TalentTier) => void;
} = $props();
const available = (tier: TalentTier) => talent.tiers?.some((entry) => entry.tier === (tier === 2 ? 'II' : 'I')) ?? false;
const choose = (tier: TalentTier) => {
value = tier;
onchange?.(tier);
};
</script>
<fieldset class="talent-tier-choice">
<legend>{label}</legend>
<div>
{#each [1, 2] as tier}
<label class:tier-two={tier === 2}>
<input type="radio" {name} value={tier} checked={value === tier} disabled={!available(tier as TalentTier)} onchange={() => choose(tier as TalentTier)}/>
<span>
<ItemIcon src={talent.iconUrl} alt="" className="talent-tier-icon"/>
<small>TIER {tier === 2 ? 'II' : 'I'}</small>
<b>T{tier}</b>
<em>{available(tier as TalentTier) ? value === tier ? 'SELECTED' : 'SELECT' : 'UNAVAILABLE'}</em>
</span>
</label>
{/each}
</div>
</fieldset>
<style>
.talent-tier-choice{min-width:0;margin:8px 0 0;padding:0;border:0}
.talent-tier-choice legend{margin-bottom:5px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.07em}
.talent-tier-choice>div{display:grid;grid-template-columns:1fr 1fr;gap:5px}
.talent-tier-choice label{min-width:0;cursor:pointer}
.talent-tier-choice input{position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}
.talent-tier-choice span{min-height:64px;display:grid;grid-template-columns:42px minmax(0,1fr) auto;grid-template-rows:auto auto;align-items:center;gap:1px 8px;padding:7px;border:1px solid var(--shd-border);background:var(--shd-builder-canvas);color:var(--shd-text);transition:var(--shd-transition)}
.talent-tier-choice :global(.talent-tier-icon){grid-row:1/3;width:40px;height:40px;object-fit:contain}
.talent-tier-choice .tier-two :global(.talent-tier-icon){filter:sepia(.3) saturate(1.45) hue-rotate(335deg) brightness(1.12)}
.talent-tier-choice small{align-self:end;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.06em}
.talent-tier-choice b{grid-column:3;grid-row:1/3;color:var(--shd-text-muted);font-size:18px}
.talent-tier-choice em{align-self:start;color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-style:normal;font-weight:900}
.talent-tier-choice label:hover span{border-color:var(--shd-border-strong);background:var(--shd-surface-active)}
.talent-tier-choice input:checked+span{border-color:var(--shd-builder-selection-border);background:var(--shd-builder-selection);box-shadow:inset 0 -2px var(--shd-orange)}
.talent-tier-choice input:checked+span b,.talent-tier-choice input:checked+span em{color:var(--shd-orange)}
.talent-tier-choice input:focus-visible+span{outline:2px solid var(--shd-focus-ring);outline-offset:2px}
.talent-tier-choice input:disabled+span{opacity:.38;cursor:not-allowed;filter:saturate(.35)}
@media(max-width:430px){.talent-tier-choice span{grid-template-columns:36px minmax(0,1fr);}.talent-tier-choice :global(.talent-tier-icon){width:34px;height:34px}.talent-tier-choice b{display:none}}
</style>
@@ -0,0 +1,97 @@
<script lang="ts">
import type { WeaponBaseline } from '$lib/builder/weapon-baseline';
type WeaponBaselineEntry = {
slot: 'PRIMARY' | 'SECONDARY';
id: string;
name: string;
damage: number | null;
rpm: number | null;
magazine: number | null;
reloadSpeed: number | null;
baseline: WeaponBaseline;
};
let { entries, headingId }: { entries: WeaponBaselineEntry[]; headingId: string } = $props();
const numberValue = (value: number | null, suffix = '') =>
value === null || !Number.isFinite(value) || value <= 0 ? 'NOT AVAILABLE' : `${value.toLocaleString(undefined, { maximumFractionDigits: 2 })}${suffix}`;
const metricValue = (metric: { value: number | null; missing: string[] }) => numberValue(metric.value, ' /s');
</script>
<section class="weapon-baseline" aria-labelledby={headingId}>
<header class="baseline-head">
<div>
<p class="eyebrow"><span></span> WEAPON OUTPUT / CATALOG BASELINE</p>
<h2 id={headingId}>Weapon output</h2>
</div>
<strong>EXPERIMENTAL</strong>
</header>
<p class="baseline-intro">Strict evidence-only throughput from the selected catalog weapon and selected damage.</p>
<div class="baseline-results" aria-live="polite" aria-atomic="false">
{#each entries as entry}
<article class="weapon-result" aria-labelledby={`${headingId}-${entry.slot.toLowerCase()}`}>
<header class="weapon-result-head">
<div>
<span>{entry.slot}</span>
<h3 id={`${headingId}-${entry.slot.toLowerCase()}`}>{entry.name}</h3>
</div>
<code>{entry.id || 'NO CATALOG ID'}</code>
</header>
<div class="baseline-inputs" aria-label={`${entry.slot} baseline inputs`}>
<span><small>SELECTED DAMAGE</small><b>{numberValue(entry.baseline.selectedDamage.value)}</b></span>
<span><small>RPM</small><b>{numberValue(entry.rpm)}</b></span>
<span><small>MAGAZINE</small><b>{numberValue(entry.magazine, ' SHOTS')}</b></span>
<span><small>RELOAD</small><b>{numberValue(entry.reloadSpeed, ' SEC')}</b></span>
</div>
<div class="baseline-metrics">
{#each [
{ label: 'RATED BODY-HIT THROUGHPUT', metric: entry.baseline.ratedBodyHitThroughput },
{ label: 'MAGAZINE-CYCLE BODY-HIT THROUGHPUT', metric: entry.baseline.magazineCycleBodyHitThroughput }
] as output}
<div class:unavailable={output.metric.value === null}>
<span>{output.label}<small>PER SECOND</small></span>
<strong>{metricValue(output.metric)}</strong>
{#if output.metric.value === null}
<p class="missing-label">MISSING FIELDS</p>
<ul>{#each output.metric.missing as field}<li>{field}</li>{/each}</ul>
{/if}
</div>
{/each}
</div>
</article>
{/each}
</div>
<div class="baseline-contract">
<p><b>FORMULAS</b> Selected damage × (RPM ÷ 60)</p>
<p>Magazine × selected damage ÷ ((Magazine ÷ (RPM ÷ 60)) + reload seconds)</p>
<p>This does not calculate accuracy, critical hits, headshots, Talents, enemy mitigation, rotations, target count, or encounter uptime.</p>
</div>
<p class="baseline-source">Source provenance: each result shows its catalog weapon ID above.</p>
</section>
<style>
.weapon-baseline{display:grid;gap:var(--shd-gap-md);padding:var(--shd-gap-md);border:1px solid var(--shd-model-border);border-top:2px solid var(--shd-orange);background:var(--shd-model-surface);min-width:0}
.baseline-head,.weapon-result-head{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--shd-gap-md)}
.baseline-head>div,.weapon-result-head>div{display:grid;gap:3px;min-width:0}
.eyebrow,.baseline-head p{margin:0;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}
.eyebrow span{display:inline-block;width:6px;height:6px;margin-right:4px;background:var(--shd-orange)}
h2,h3{margin:0;text-transform:uppercase;letter-spacing:.7px;color:var(--shd-text)}
h2{font-size:var(--shd-type-body)}h3{font-size:var(--shd-type-supporting)}
.baseline-head>strong{padding:5px 7px;border:1px solid var(--shd-orange);color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px;white-space:nowrap}
.baseline-intro,.baseline-contract p,.baseline-source{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}
.baseline-results{display:grid;gap:var(--shd-gap-sm)}
.weapon-result{display:grid;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-border);background:var(--shd-surface)}
.weapon-result-head span,.baseline-inputs small,.baseline-metrics span,.missing-label{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
.weapon-result-head code{max-width:45%;overflow:hidden;color:var(--shd-text-dim);font:var(--shd-type-telemetry) var(--shd-font-mono);text-overflow:ellipsis;white-space:nowrap}
.baseline-inputs{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border-block:1px solid var(--shd-border-soft)}
.baseline-inputs span{display:grid;gap:3px;padding:7px;border-left:1px solid var(--shd-border-soft);min-width:0}.baseline-inputs span:first-child{border-left:0}
.baseline-inputs b{font-size:var(--shd-type-supporting);font-weight:700;overflow-wrap:anywhere}
.baseline-metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--shd-gap-sm)}
.baseline-metrics>div{display:grid;gap:5px;padding:9px;border-left:2px solid var(--shd-success);background:var(--shd-bg);min-width:0}.baseline-metrics>div.unavailable{border-left-color:var(--shd-danger)}
.baseline-metrics span{display:grid;gap:2px}.baseline-metrics span small{color:var(--shd-text-dim);font-size:10px}.baseline-metrics strong{font-size:18px;color:var(--shd-success)}.baseline-metrics .unavailable strong{color:var(--shd-danger)}
.missing-label{margin:2px 0 0;color:var(--shd-danger)}.baseline-metrics ul{margin:0;padding-left:17px;color:var(--shd-danger);font:var(--shd-type-telemetry)/1.4 var(--shd-font-mono)}
.baseline-contract{display:grid;gap:5px;padding-top:var(--shd-gap-sm);border-top:1px solid var(--shd-border)}.baseline-contract b{color:var(--shd-text);letter-spacing:.7px}.baseline-source{font-size:var(--shd-type-telemetry)}
@media(max-width:620px){.baseline-head{display:grid}.baseline-inputs{grid-template-columns:repeat(2,minmax(0,1fr))}.baseline-inputs span:nth-child(odd){border-left:0}.baseline-inputs span:nth-child(n+3){border-top:1px solid var(--shd-border-soft)}.baseline-metrics{grid-template-columns:1fr}.weapon-result-head code{max-width:50%}}
@media(prefers-reduced-motion:reduce){.weapon-baseline *{scroll-behavior:auto;transition:none!important;animation:none!important}}
</style>
@@ -9,6 +9,8 @@
blankConditionalMechanicDraft,
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,38 +1139,53 @@
</div>
<div class="mutation-panel">
<div class="outcome-heading"><div><span>STATE CHANGES</span><strong>{clause.mutations.length}</strong></div><button type="button" class="secondary compact-action" onclick={() => addMutation(clauseIndex)} disabled={!rule.pools.length}>ADD STATE CHANGE +</button></div>
<div class="outcome-heading"><div><span>STATE CHANGES</span><strong>{clause.mutations.length}</strong></div><div class="mutation-actions"><button type="button" class="secondary compact-action" onclick={() => addMutation(clauseIndex)} disabled={!rule.pools.length}>ADD STATE CHANGE +</button>{#if allowAssignedAbility}<button type="button" class="secondary compact-action" onclick={() => addAbilityDurationExtension(clauseIndex)}>EXTEND ABILITY +</button>{/if}<button type="button" class="secondary compact-action" onclick={() => addCooldownOnMax(clauseIndex)} disabled={rule.pools.filter((pool) => pool.max && pool.max > 0).length < 2}>ADD MAX COOLDOWN +</button></div></div>
{#each clause.mutations as mutation, mutationIndex}
<div class="mutation-row">
<SegmentedChoice
legend="OPERATION"
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'clear',label:'CLEAR'}]}
value={mutation.kind === 'none' || mutation.kind === 'derive' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'clear')}
/>
<SegmentedChoice
legend="STATE POOL"
name={`${idPrefix}-mutation-pool-${clauseIndex}-${mutationIndex}`}
options={rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))}
value={'pool' in mutation ? mutation.pool : rule.pools[0]?.key ?? ''}
columns={Math.min(4,Math.max(1,rule.pools.length))}
onchange={(values) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'clear' ? 'clear' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])}
/>
{#if mutation.kind !== 'clear'}
<label class="field">VALUE<input type="number" step="any" value={'value' in mutation && typeof mutation.value === 'number' ? mutation.value : 1} oninput={(event) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',undefined,Number(event.currentTarget.value))}/></label>
{/if}
{#if 'pool' in mutation && rule.pools.find((pool) => pool.key === mutation.pool)?.scope === 'target'}
{#if mutation.kind === 'extend_duration'}
<div class="mutation-row ability-mutation-row">
<div class="branch-readout"><span>OPERATION</span><strong>EXTEND ASSIGNED ABILITY</strong></div>
<label class="field">SECONDS<input type="number" min="0" step="any" value={typeof mutation.seconds === 'number' ? mutation.seconds : 0} oninput={(event) => updateAbilityDurationExtension(clauseIndex,mutationIndex,event.currentTarget.value)}/><small>Applies only while the assigned ability remains active.</small></label>
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
</div>
{:else if mutation.kind === 'start_cooldown_on_max'}
<div class="mutation-row cooldown-mutation-row">
<div class="branch-readout"><span>OPERATION</span><strong>START COOLDOWN ON MAX</strong></div>
<SegmentedChoice legend="STACK POOL" name={`${idPrefix}-cooldown-stack-${clauseIndex}-${mutationIndex}`} options={rule.pools.filter((pool) => pool.max && pool.max > 0).map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))} value={mutation.stackPool} onchange={(values) => updateCooldownOnMax(clauseIndex,mutationIndex,{stackPool:values[0]})}/>
<SegmentedChoice legend="COOLDOWN POOL" name={`${idPrefix}-cooldown-pool-${clauseIndex}-${mutationIndex}`} options={rule.pools.filter((pool) => pool.scope === 'agent' && pool.expiresAfterSeconds && pool.key !== mutation.stackPool).map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))} value={mutation.cooldownPool} onchange={(values) => updateCooldownOnMax(clauseIndex,mutationIndex,{cooldownPool:values[0]})}/>
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
</div>
{:else}
<div class="mutation-row">
<SegmentedChoice
legend="STATE OWNER"
name={`${idPrefix}-mutation-entity-${clauseIndex}-${mutationIndex}`}
options={[{value:'event_target',label:'EVENT TARGET'},{value:'event_actor',label:'EVENT ACTOR'}]}
value={'entity' in mutation && mutation.entity?.kind === 'event_actor' ? 'event_actor' : 'event_target'}
onchange={(values) => updateMutationEntity(clauseIndex,mutationIndex,values[0] as 'event_target'|'event_actor')}
legend="OPERATION"
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'clear',label:'CLEAR'}]}
value={mutation.kind === 'none' || mutation.kind === 'derive' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'clear')}
/>
{/if}
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
</div>
<SegmentedChoice
legend="STATE POOL"
name={`${idPrefix}-mutation-pool-${clauseIndex}-${mutationIndex}`}
options={rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))}
value={'pool' in mutation ? mutation.pool : rule.pools[0]?.key ?? ''}
columns={Math.min(4,Math.max(1,rule.pools.length))}
onchange={(values) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'clear' ? 'clear' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])}
/>
{#if mutation.kind !== 'clear'}
<label class="field">VALUE<input type="number" step="any" value={'value' in mutation && typeof mutation.value === 'number' ? mutation.value : 1} oninput={(event) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',undefined,Number(event.currentTarget.value))}/></label>
{/if}
{#if 'pool' in mutation && rule.pools.find((pool) => pool.key === mutation.pool)?.scope === 'target'}
<SegmentedChoice
legend="STATE OWNER"
name={`${idPrefix}-mutation-entity-${clauseIndex}-${mutationIndex}`}
options={[{value:'event_target',label:'EVENT TARGET'},{value:'event_actor',label:'EVENT ACTOR'}]}
value={'entity' in mutation && mutation.entity?.kind === 'event_actor' ? 'event_actor' : 'event_target'}
onchange={(values) => updateMutationEntity(clauseIndex,mutationIndex,values[0] as 'event_target'|'event_actor')}
/>
{/if}
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
</div>
{/if}
{:else}
<p class="empty-state">This branch emits outcomes without changing shared state.</p>
{/each}
@@ -1049,25 +1196,24 @@
<div class="outcome-card">
<div class="outcome-grid">
<label class="field">LABEL<input value={outcome.label} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label>
<SegmentedChoice legend="KIND" name={`${idPrefix}-kind-${clauseIndex}-${outcomeIndex}`} options={outcomeKinds} value={outcome.kind} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{kind:values[0] as ConditionalMechanicOutcomeDraft['kind'],resource:values[0] === 'restoration' || values[0] === 'resource_cost' ? outcome.resource ?? 'health' : undefined,attributeId:values[0] === 'attribute_modifier' ? outcome.attributeId ?? '' : undefined,delivery:values[0] === 'restoration' ? outcome.delivery : undefined,durationSeconds:values[0] === 'restoration' ? outcome.durationSeconds : undefined})}/>
<SegmentedChoice legend="KIND" name={`${idPrefix}-kind-${clauseIndex}-${outcomeIndex}`} options={outcomeKinds} value={outcome.kind} onchange={(values) => setOutcomeKind(clauseIndex,outcomeIndex,values[0] as ConditionalMechanicOutcomeDraft['kind'])}/>
<label class="field">DESCRIPTION SLOT<input type="number" min="1" step="1" value={outcome.descriptionSlot ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{descriptionSlot:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
</div>
<SegmentedChoice legend="TARGET SCOPE" name={`${idPrefix}-scope-${clauseIndex}-${outcomeIndex}`} options={scopeOptions} value={outcome.scope} columns={scopeOptions.length > 4 ? 4 : scopeOptions.length} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{scope:values[0] as MechanicOutcomeScope})}/>
<SegmentedChoice legend="TARGET SCOPE" name={`${idPrefix}-scope-${clauseIndex}-${outcomeIndex}`} options={scopeOptions} value={outcome.scope} columns={scopeOptions.length > 4 ? 4 : scopeOptions.length} onchange={(values) => setOutcomeScope(clauseIndex,outcomeIndex,values[0] as MechanicOutcomeScope)}/>
{#if outcome.scope === 'allies' || outcome.scope === 'self_and_allies' || outcome.scope === 'target'}
<div class="delivery-controls">
<SegmentedChoice
legend="AREA TARGETING"
name={`${idPrefix}-targeting-${clauseIndex}-${outcomeIndex}`}
options={[{value:'none',label:'NONE'},{value:'allies',label:'ALLIES IN RADIUS'},{value:'enemies',label:'ENEMIES IN RADIUS'}]}
value={outcome.targeting?.population ?? 'none'}
onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{
targeting: values[0] === 'none'
? undefined
: {kind:'within_radius',population:values[0] as 'allies'|'enemies',radiusMeters:outcome.targeting?.radiusMeters ?? 20}
})}
options={targetingOptions(clause,outcome)}
value={mechanicTargetingMode(outcome.targeting)}
onchange={(values) => setOutcomeTargeting(clauseIndex,outcomeIndex,values[0] as MechanicTargetingMode)}
/>
{#if outcome.targeting}
<label class="field">RADIUS (METERS)<input type="number" min="0.01" step="any" value={outcome.targeting.radiusMeters} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{targeting:{...outcome.targeting!,radiusMeters:Number(event.currentTarget.value)}})}/></label>
{#if outcome.targeting.kind === 'event_target_area'}
<p class="area-targeting-note"><strong>EVENT TARGET CENTER</strong><span>Damages each valid enemy at or inside the radius once. Runtime events must supply the authoritative target sample.</span></p>
{/if}
{/if}
</div>
{/if}
@@ -1154,8 +1300,9 @@
<label class="field">{column.label} VALUE<input type="number" step="any" value={outcome.variants[column.variantKey]?.value ?? 0} oninput={(event) => updateVariant(clauseIndex,outcomeIndex,column.variantKey,event.currentTarget.value)}/></label>
{/each}
</div>
<label class="field probability-field">OUTCOME CHANCE · %<input type="number" min="0" max="100" step="any" value={outcome.probabilityPercent ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{probabilityPercent:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/><small>Each eligible outcome rolls independently. Maximum Potential resolves each nonzero chance as successful.</small></label>
<div class="outcome-footer">
<SegmentedChoice legend="PHASE" name={`${idPrefix}-phase-${clauseIndex}-${outcomeIndex}`} options={[{value:'on_event',label:'ON EVENT'},{value:'after_event',label:'AFTER EVENT'},{value:'while_active',label:'WHILE ACTIVE'},{value:'on_enter',label:'ON ENTER'},{value:'on_exit',label:'ON EXIT'}]} value={outcome.phase} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{phase:values[0] as OutcomePhase})}/>
<SegmentedChoice legend="PHASE" name={`${idPrefix}-phase-${clauseIndex}-${outcomeIndex}`} options={phaseOptions(outcome)} value={outcome.phase} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{phase:values[0] as OutcomePhase})}/>
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{outcomes:clause.outcomes.filter((_,index) => index !== outcomeIndex)})}>REMOVE OUTCOME</button>
</div>
</div>
@@ -1198,6 +1345,8 @@
.branch>header>div{display:flex;align-items:end;gap:9px}.branch>header>div>span{align-self:center;color:var(--shd-orange);font:900 var(--shd-type-telemetry)/1 var(--shd-font-mono)}.branch>header .field{min-width:min(360px,55vw);margin:0}
.branch-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr)) minmax(160px,.45fr);align-items:end;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)}
.condition-controls{display:grid;grid-template-columns:minmax(260px,.7fr) minmax(320px,1.3fr);align-items:end;gap:var(--shd-gap-sm);padding:0 var(--shd-gap-sm) var(--shd-gap-sm)}
.ability-proc-controls{display:grid;grid-template-columns:minmax(240px,.7fr) minmax(180px,.4fr);align-items:end;gap:var(--shd-gap-sm)}
.ability-proc-controls .field small,.proc-chance-field small,.probability-field small,.ability-mutation-row .field small{display:block;margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.3}
.health-boundaries{display:grid;grid-template-columns:repeat(2,minmax(150px,1fr));gap:var(--shd-gap-sm)}
.health-boundaries .field small{display:block;margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.3}
.predicate-fields{display:grid;grid-template-columns:minmax(170px,1fr) minmax(190px,.8fr);align-items:end;gap:var(--shd-gap-sm)}
@@ -1207,12 +1356,15 @@
.branch-readout{display:grid;align-content:center;gap:3px;min-height:44px;padding:7px 9px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}.branch-readout span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.branch-readout strong{font-size:var(--shd-type-supporting)}
.reducer-controls{display:grid;grid-template-columns:minmax(0,1fr) repeat(2,minmax(130px,.25fr));align-items:end;gap:var(--shd-gap-sm);padding:0 var(--shd-gap-sm) var(--shd-gap-sm)}
.mutation-panel{margin:0 var(--shd-gap-sm) var(--shd-gap-sm);border:1px solid var(--shd-border-soft)}
.mutation-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}
.mutation-row{display:grid;grid-template-columns:minmax(260px,1fr) minmax(220px,1fr) minmax(110px,.35fr) minmax(240px,1fr) auto;align-items:end;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border-soft)}
.ability-mutation-row{grid-template-columns:minmax(240px,1fr) minmax(180px,.5fr) auto}.cooldown-mutation-row{grid-template-columns:minmax(210px,.8fr) minmax(220px,1fr) minmax(220px,1fr) auto}.probability-field{max-width:260px}
.outcome-card{display:grid;gap:8px;margin:var(--shd-gap-sm);padding:9px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}
.outcome-grid{display:grid;grid-template-columns:minmax(200px,1fr) minmax(360px,1.4fr) minmax(120px,.3fr);align-items:end;gap:8px}
.variant-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px}
.state-scaling{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr) repeat(2,minmax(110px,.3fr));align-items:end;gap:8px}
.delivery-controls{display:grid;grid-template-columns:minmax(260px,1fr) minmax(150px,.35fr);align-items:end;gap:8px}.delivery-controls>.field{margin:0}
.area-targeting-note{grid-column:1/-1;display:grid;gap:2px;margin:0;padding:7px 9px;border-left:2px solid var(--shd-info);background:var(--shd-model-surface);color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.area-targeting-note strong{color:var(--shd-info);font-size:var(--shd-type-telemetry);letter-spacing:.7px}
.outcome-footer{display:flex;align-items:end;justify-content:space-between;gap:8px}
.remove,.danger-quiet{min-height:38px;padding:0 10px;border:1px solid var(--shd-danger);background:transparent;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}
.remove:hover,.remove:focus-visible,.danger-quiet:hover,.danger-quiet:focus-visible{background:var(--shd-danger);color:var(--shd-surface)}
+15
View File
@@ -3,6 +3,11 @@ import type { GameDataTargetType } from '$lib/game-data-targets';
export type DatabaseBadge = { label: string; tone?: string };
export type DatabaseMeter = { label: string; value: number };
export type DatabaseWeaponModSlot = {
slot: string;
unlockedAtTierOne: boolean;
iconUrl?: string;
};
export type DatabaseFact = {
label: string;
value: string;
@@ -26,8 +31,10 @@ export type DatabaseItem = {
renderUrl?: string;
gameIcon?: GameIconName;
headerBadge?: string;
headerBadgeTone?: string;
headerBadges?: string[];
headerMetaIcon?: GameIconName;
topBorderTone?: string;
badges?: DatabaseBadge[];
facts: DatabaseFact[];
factLayout?: 'grid' | 'stack';
@@ -35,9 +42,17 @@ export type DatabaseItem = {
lines?: DatabaseFact[];
afterLines?: DatabaseFact[];
iconGroups?: DatabaseIconGroup[];
weaponModSlots?: DatabaseWeaponModSlot[];
description?: string;
secondaryDescription?: string;
descriptionStyle?: 'prose' | 'data';
protocolPreview?: {
primaryStat: string;
primaryUnit: string;
primaryBaseValue: number;
talentDescription?: string;
talentEffects: Array<{ baseValue: number; unit: string }>;
};
};
export type DatabaseRelatedItem = {
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest';
import {
MOD_SECONDARY_CAPACITY,
normalizeModSecondaryPool,
} from './secondary-pool';
const endpoints = (
level1Min: number | string,
level1Max: number | string,
level20Min: number | string,
level20Max: number | string,
) => ({ level1Min, level1Max, level20Min, level20Max });
const range = (statId: string) => ({
statId,
values: {
Specialized: endpoints('1.5', '2.5', '3.5', '4.5'),
Superior: endpoints(2, 3, 4, 5),
'High-end': endpoints(3, 4, 5, 6),
},
});
describe('shared Mod secondary pool', () => {
it('defines secondary capacity for every Mod quality', () => {
expect(MOD_SECONDARY_CAPACITY).toEqual({
Standard: 0,
Specialized: 1,
Superior: 2,
'High-end': 2,
});
});
it('normalizes Attribute IDs and valid Level 1/20 endpoints', () => {
const result = normalizeModSecondaryPool([
range(' critical-hit-chance '),
range('critical-hit-damage'),
]);
expect(result.error).toBeNull();
expect(result.pool).toEqual([
{
statId: 'critical-hit-chance',
values: {
Specialized: endpoints(1.5, 2.5, 3.5, 4.5),
Superior: endpoints(2, 3, 4, 5),
'High-end': endpoints(3, 4, 5, 6),
},
},
{
statId: 'critical-hit-damage',
values: {
Specialized: endpoints(1.5, 2.5, 3.5, 4.5),
Superior: endpoints(2, 3, 4, 5),
'High-end': endpoints(3, 4, 5, 6),
},
},
]);
});
it('rejects duplicate Attributes after ID normalization', () => {
const result = normalizeModSecondaryPool([
range('critical-hit-chance'),
range(' critical-hit-chance '),
]);
expect(result).toEqual({
pool: [],
error: 'Choose a unique Attribute for every shared pool option.',
});
});
it.each([
[
'missing quality ranges',
{
...range('critical-hit-chance'),
values: {
Specialized: endpoints(1, 2, 3, 4),
Superior: endpoints(2, 3, 4, 5),
},
},
],
[
'reversed ranges',
{
...range('critical-hit-chance'),
values: {
...range('critical-hit-chance').values,
Specialized: endpoints(2, 1, 3, 4),
},
},
],
[
'Level 20 regression',
{
...range('critical-hit-chance'),
values: {
...range('critical-hit-chance').values,
Specialized: endpoints(2, 3, 1, 2),
},
},
],
])('rejects %s', (_label, invalidRange) => {
const result = normalizeModSecondaryPool([
invalidRange,
range('critical-hit-damage'),
]);
expect(result).toEqual({
pool: [],
error:
'Configure valid Level 1 and Level 20 ranges for Specialized, Superior, and High-end quality.',
});
});
});
+102
View File
@@ -0,0 +1,102 @@
export const MOD_QUALITIES = [
'Standard',
'Specialized',
'Superior',
'High-end',
] as const;
export const MOD_SECONDARY_QUALITIES = [
'Specialized',
'Superior',
'High-end',
] as const;
export const MOD_SECONDARY_CAPACITY = {
Standard: 0,
Specialized: 1,
Superior: 2,
'High-end': 2,
} as const satisfies Record<(typeof MOD_QUALITIES)[number], number>;
export type ModSecondaryQuality = (typeof MOD_SECONDARY_QUALITIES)[number];
export type ModSecondaryRange = {
statId: string;
values: Record<
ModSecondaryQuality,
{
level1Min: number;
level1Max: number;
level20Min: number;
level20Max: number;
}
>;
};
export function normalizeModSecondaryPool(input: unknown): {
pool: ModSecondaryRange[];
error: string | null;
} {
if (!Array.isArray(input) || input.length < 2 || input.length > 50) {
return {
pool: [],
error: 'Configure between two and 50 shared secondary Attributes.',
};
}
const pool = input.map((candidate) => {
const range = candidate as Partial<ModSecondaryRange>;
return {
statId: typeof range.statId === 'string' ? range.statId.trim() : '',
values: Object.fromEntries(
MOD_SECONDARY_QUALITIES.map((quality) => {
const values = range.values?.[quality] as
| Partial<ModSecondaryRange['values'][ModSecondaryQuality]>
| undefined;
return [
quality,
{
level1Min: Number(values?.level1Min),
level1Max: Number(values?.level1Max),
level20Min: Number(values?.level20Min),
level20Max: Number(values?.level20Max),
},
];
}),
) as ModSecondaryRange['values'],
};
});
if (
pool.some((range) => !range.statId) ||
new Set(pool.map((range) => range.statId)).size !== pool.length
) {
return {
pool: [],
error: 'Choose a unique Attribute for every shared pool option.',
};
}
if (
pool.some((range) =>
MOD_SECONDARY_QUALITIES.some((quality) => {
const values = range.values[quality];
return (
Object.values(values).some((value) => !Number.isFinite(value)) ||
values.level1Max < values.level1Min ||
values.level20Max < values.level20Min ||
values.level20Min < values.level1Min ||
values.level20Max < values.level1Max
);
}),
)
) {
return {
pool: [],
error:
'Configure valid Level 1 and Level 20 ranges for Specialized, Superior, and High-end quality.',
};
}
return { pool, error: null };
}
@@ -94,7 +94,7 @@ describe('AI mechanic authoring prompt', () => {
}),
);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(7);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
expect.arrayContaining(['delivery', 'durationSeconds']),
);
@@ -136,7 +136,7 @@ describe('AI mechanic authoring prompt', () => {
]),
);
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
'Prompt contract version: 7',
'Prompt contract version: 10',
);
const advertised = structuredClone(
@@ -185,7 +185,7 @@ describe('AI mechanic authoring prompt', () => {
expect(parsed.ok).toBe(true);
});
it('advertises the canonical Extra Health change event contract in version 7', () => {
it('advertises the canonical Extra Health change event contract in version 10', () => {
const input = JSON.parse(
buildAiMechanicAuthoringInput({
request,
@@ -196,9 +196,9 @@ describe('AI mechanic authoring prompt', () => {
}),
);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(7);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
'Prompt contract version: 7',
'Prompt contract version: 10',
);
expect(input.canonicalCatalog.events).toContainEqual({
key: 'extra_health_changed',
@@ -222,6 +222,144 @@ describe('AI mechanic authoring prompt', () => {
});
});
it('advertises assigned-ability activity for kill and assist predicates', () => {
const input = JSON.parse(
buildAiMechanicAuthoringInput({
request,
attributes: [],
events: [
MECHANIC_EVENT_REGISTRY.enemy_killed,
MECHANIC_EVENT_REGISTRY.assist,
],
actions: [],
recipeKeys: [],
}),
);
for (const event of input.canonicalCatalog.events) {
expect(event.fields).toEqual(
expect.arrayContaining([
{
path: 'values.assignedAbilityActive',
label: 'Assigned ability active',
type: 'boolean',
},
]),
);
}
expect(input.examplesAndRules).toEqual(
expect.arrayContaining([
expect.stringContaining('values.assignedAbilityActive'),
]),
);
});
it('advertises and round-trips aimed event-target area damage', () => {
const protocolRequest: AiMechanicAuthoringRequest = {
...request,
context: {
sourceKind: 'os_protocol_talent',
sourceName: 'Untitled mechanic',
sourceDescription:
'Critical Hits performed during aiming create an explosion with a radius of 5 meters around the target that deals damage equal to 2000% Firepower to them.',
},
valueColumns: [{ key: 'base', variantKey: 'base', label: 'BASE' }],
allowProtocolUpgradeScaling: true,
};
const input = JSON.parse(
buildAiMechanicAuthoringInput({
request: protocolRequest,
attributes: [
{
id: 'firepower-id',
name: 'Firepower',
description: null,
unit: '',
type: null,
category: null,
calculationType: null,
},
],
events: [MECHANIC_EVENT_REGISTRY.weapon_hit],
actions: [],
recipeKeys: [],
}),
);
expect(input.canonicalCatalog.events[0].fields).toContainEqual({
path: 'values.aiming',
label: 'Aiming down sights',
type: 'boolean',
});
expect(
input.canonicalCatalog.grammar.outcome.targeting.eventTargetArea,
).toMatchObject({
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
});
expect(input.examplesAndRules).toEqual(
expect.arrayContaining([
expect.stringContaining('values.aiming'),
expect.stringContaining('event_target_area'),
]),
);
const advertised = structuredClone(
input.canonicalCatalog.grammar.aimedCriticalAreaDamageShape,
);
const outcome = advertised.clauses[0].outcomes[0];
const base = JSON.parse(
JSON.stringify(
outcome.variants['replace-with-each-required-variant-key'],
).replaceAll(
'replace-with-an-exact-catalog-attribute-id',
'firepower-id',
),
);
outcome.variants = { base };
const parsed = parseConditionalMechanicDraft(advertised, {
variantKeys: ['base'],
allowedAttributeIds: new Set(['firepower-id']),
});
expect(parsed.ok).toBe(true);
});
it('advertises reproducible outcome and clause proc probabilities in version 10', () => {
const input = JSON.parse(
buildAiMechanicAuthoringInput({
request,
attributes: [],
events: [MECHANIC_EVENT_REGISTRY.skill_hit],
actions: [],
recipeKeys: [],
}),
);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
expect(input.canonicalCatalog.grammar.clause.optional).toEqual(
expect.arrayContaining([
'procChancePercent',
'requiresAssignedAbilityActive',
]),
);
expect(input.canonicalCatalog.grammar.mutationKinds).toEqual(
expect.arrayContaining(['extend_duration', 'start_cooldown_on_max']),
);
expect(
input.canonicalCatalog.grammar.probabilityOutcomeShape,
).toMatchObject({
probabilityPercent: 29,
resource: 'ammo',
});
expect(input.examplesAndRules).toEqual(
expect.arrayContaining([
expect.stringContaining('procChancePercent'),
expect.stringContaining('reproducible seeded random stream'),
]),
);
});
it('advertises authoritative current Health percentage with strict band semantics', () => {
const input = JSON.parse(
buildAiMechanicAuthoringInput({
+107 -2
View File
@@ -3,7 +3,7 @@ import { CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES } from '$lib/calculations/mec
import { MECHANIC_COMBAT_REFERENCE_REGISTRY } from '$lib/calculations/mechanic';
import type { AiMechanicAttribute } from './mechanic-authoring-context';
export const AI_MECHANIC_PROMPT_VERSION = 7;
export const AI_MECHANIC_PROMPT_VERSION = 10;
const mechanicsGrammar = {
version: 1,
@@ -40,7 +40,14 @@ const mechanicsGrammar = {
},
clause: {
required: ['key', 'event', 'if', 'reducer', 'mutations', 'outcomes'],
optional: ['label', 'recipeKey', 'cooldownSeconds', 'weight'],
optional: [
'label',
'recipeKey',
'cooldownSeconds',
'weight',
'procChancePercent',
'requiresAssignedAbilityActive',
],
},
predicateKinds: ['true', 'exists', 'compare', 'all', 'any', 'not'],
compareOperators: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'],
@@ -63,6 +70,8 @@ const mechanicsGrammar = {
'derive',
'clear',
'transfer',
'extend_duration',
'start_cooldown_on_max',
],
entitySelectors: ['agent', 'event_actor', 'event_target', 'event_field'],
valueReferences: [
@@ -70,6 +79,7 @@ const mechanicsGrammar = {
'scenario',
'combat',
'equipment',
'assigned_ability',
'attribute',
'reducer',
'pool',
@@ -97,6 +107,7 @@ const mechanicsGrammar = {
},
},
{ ref: { source: 'equipment', path: 'protocolTalentUpgrades' } },
{ ref: { source: 'assigned_ability', path: 'remainingDurationSeconds' } },
{
ref: {
source: 'attribute',
@@ -200,6 +211,16 @@ const mechanicsGrammar = {
value: 'all',
clearSource: true,
},
{
kind: 'extend_duration',
target: 'assigned_ability',
seconds: 0.3,
},
{
kind: 'start_cooldown_on_max',
stackPool: 'extension-stacks',
cooldownPool: 'extension-cooldown',
},
],
outcome: CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES,
continuousRestorationOutcomeShape: {
@@ -232,6 +253,83 @@ const mechanicsGrammar = {
},
},
},
probabilityOutcomeShape: {
key: 'recover-ammo',
label: 'Recover Ammo',
phase: 'after_event',
kind: 'restoration',
scope: 'self',
basis: 'flat',
resource: 'ammo',
probabilityPercent: 29,
variants: {
'replace-with-each-required-variant-key': { value: 1 },
},
},
aimedCriticalAreaDamageShape: {
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [],
clauses: [
{
key: 'aimed-critical-explosion',
label: 'Aimed critical hit explosion',
event: 'weapon_hit',
if: {
kind: 'all',
predicates: [
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
{
kind: 'compare',
left: { source: 'event', path: 'values.critical' },
operator: 'eq',
right: true,
},
{
kind: 'compare',
left: { source: 'event', path: 'values.aiming' },
operator: 'eq',
right: true,
},
],
},
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
key: 'critical-explosion-damage',
label: 'Critical explosion damage',
phase: 'after_event',
kind: 'direct_damage',
scope: 'target',
basis: 'flat',
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
},
variants: {
'replace-with-each-required-variant-key': {
value: 2000,
expression: {
op: 'multiply',
left: 20,
right: {
ref: {
source: 'attribute',
attributeId: 'replace-with-an-exact-catalog-attribute-id',
},
},
},
},
},
},
],
},
],
},
preciseTraumaShape: {
version: 1,
simulationReadiness: 'ready',
@@ -453,9 +551,16 @@ export function buildAiMechanicAuthoringInput(input: {
'A target-scoped pool owns an independent value and expiry timer for each entity. Bind hit-created target state with entity:{kind:"event_target"}; do not collapse different enemies into one Agent-scoped pool.',
'Writing a target-scoped pool refreshes only that target entity timer. An on_exit outcome runs when that entity expires and uses the entity previous state for stack bands and perStack multiplication, even though the stored pool value has already reset.',
'When an outcome value is exactly a live Attribute amount, use the Attribute reference directly as the variant expression. Divide by 100 only when a displayed percentage is being used as a multiplier.',
'A weapon_hit may compare values.aiming with true only when the wording requires aiming down sights at hit resolution. Missing aiming evidence does not match; do not replace it with a scenario assumption.',
'An explosion centered on the hit target uses target-scoped direct_damage with targeting kind event_target_area, center event_target, validTargets enemies, and the stated positive radiusMeters. The runtime applies one equal damage emission to each authoritative valid enemy at or inside the radius. Do not use legacy within_radius metadata for explosion damage.',
'Precise Trauma is representable as one target-scoped stack pool with a one-second expiry: body shots add one stack, headshots add two, and one on_exit direct-damage outcome uses the expiring target previous stack count. This models the stated one-second debuff without inventing generic repeated target ticks.',
'OS Protocol upgrade scaling may use equipment.protocolTalentUpgrades only when allowProtocolUpgradeScaling is true.',
'Assigned-ability scope may be used only when allowAssignedAbility is true.',
'When allowAssignedAbility is true, a kill or assist that must occur while the source item\'s assigned ability is active uses enemy_killed or assist with {source:"event",path:"values.assignedAbilityActive"} compared to true. This field is optional on the event; a missing value does not match.',
'When allowAssignedAbility is true, requiresAssignedAbilityActive gates a clause on the runtime assigned-ability state. skill_deployed and signature_ability_deployed may supply values.assignedAbilityDurationSeconds to begin that state. At an exact expiry timestamp, the ability expires before a later clause can extend it.',
'procChancePercent is a clause-level independent percentage roll after its predicate and cooldown checks but before the reducer, mutations, and outcomes. Use it when a successful proc changes state. probabilityPercent is an outcome-level independent percentage roll for one outcome only.',
'Custom simulation uses a reproducible seeded random stream. Maximum Potential resolves every nonzero chance as successful. Never use clause weight as a proc chance.',
'extend_duration may target only assigned_ability and does nothing while the ability is inactive. start_cooldown_on_max starts a configured expiring Agent cooldown pool only when a mutation in the same clause transitions its capped stack pool to its maximum.',
'If cadence is not stated, ask rather than assuming a one-second interval.',
'If wording contains separate actions such as restoration on a periodic snapshot and a health cost on every shot, represent them as separate clauses.',
'A pool with expiresAfterSeconds refreshes the shared expiry for every current stack whenever any mutation writes that pool. Use it for mechanics where gaining a stack refreshes the duration of the full stack pool.',
@@ -9,21 +9,35 @@ describe('AI mechanic authoring rate limit', () => {
beforeEach(() => resetAiMechanicRateLimitForTests());
it('allows only one in-flight request per account', () => {
const release = reserveAiMechanicGeneration('agent-1', 1_000);
expect(() => reserveAiMechanicGeneration('agent-1', 1_001)).toThrow(
const release = reserveAiMechanicGeneration('agent-1', false, 1_000);
expect(() => reserveAiMechanicGeneration('agent-1', false, 1_001)).toThrow(
AiMechanicRateLimitError,
);
release();
expect(() => reserveAiMechanicGeneration('agent-1', 1_002)).not.toThrow();
expect(() =>
reserveAiMechanicGeneration('agent-1', false, 1_002),
).not.toThrow();
});
it('enforces the hourly account limit', () => {
for (let request = 0; request < 10; request += 1) {
const release = reserveAiMechanicGeneration('agent-1', 10_000 + request);
const release = reserveAiMechanicGeneration(
'agent-1',
false,
10_000 + request,
);
release();
}
expect(() => reserveAiMechanicGeneration('agent-1', 10_100)).toThrow(
expect(() => reserveAiMechanicGeneration('agent-1', false, 10_100)).toThrow(
'This account has reached the hourly mechanic authoring limit.',
);
});
it('does not limit administrators', () => {
const releases = Array.from({ length: 101 }, () =>
reserveAiMechanicGeneration('admin-1', true, 10_000),
);
for (const release of releases) release();
});
});
@@ -27,7 +27,12 @@ const retryAfter = (requests: number[], now: number) =>
* duplicate submissions and basic abuse; OpenAI project budgets remain the hard
* cross-instance spending boundary.
*/
export function reserveAiMechanicGeneration(userId: string, now = Date.now()) {
export function reserveAiMechanicGeneration(
userId: string,
isAdmin = false,
now = Date.now(),
) {
if (isAdmin) return () => {};
if (inFlightUsers.has(userId)) {
throw new AiMechanicRateLimitError(
'A mechanic authoring request is already running for this account.',
+2 -2
View File
@@ -77,7 +77,7 @@ export async function syncArticleTags(database: Database) {
`specialization-${item.id}`,
'specialization',
String(item.name),
`specialization-${slugify(String(item.name))}`,
`specialization-${slugify(String(item.name))}-${item.id}`,
'specialization',
item.id,
Number(item.active) ? 1 : 0,
@@ -93,7 +93,7 @@ export async function syncArticleTags(database: Database) {
`focus-${item.id}`,
'focus',
name,
`focus-${slugify(name)}`,
`focus-${slugify(name)}-${item.id}`,
'focus',
item.id,
Number(item.active) ? 1 : 0,
+2 -1
View File
@@ -34,6 +34,7 @@ export async function recordIsacAudit(input: {
export async function auditRecord(
table: string,
id: string,
suppliedDatabase?: Database,
): Promise<Record<string, unknown> | null> {
if (
![
@@ -56,7 +57,7 @@ export async function auditRecord(
) {
throw new Error('Unsupported audit table');
}
const database = await getDatabase();
const database = suppliedDatabase ?? (await getDatabase());
return (
(await (
await database.prepare(`SELECT * FROM ${table} WHERE id=?`)
+54 -2
View File
@@ -187,6 +187,7 @@ describe('conditional mechanic persistence adapter', () => {
value_scaling TEXT, unit TEXT, damage_basis TEXT, application_phase TEXT,
combat_timing TEXT, resource_kind TEXT, delivery_mode TEXT,
delivery_duration_seconds REAL, targeting_json TEXT, min_state_value REAL, max_state_value REAL,
probability_percent REAL,
description_slot INTEGER, sort_order INTEGER, created_at TEXT, updated_at TEXT
)`);
@@ -220,6 +221,8 @@ describe('conditional mechanic persistence adapter', () => {
key: 'weapon-kill',
label: 'Weapon kill',
event: 'enemy_killed',
procChancePercent: 29,
requiresAssignedAbilityActive: true,
if: {
kind: 'compare',
left: {
@@ -255,6 +258,7 @@ describe('conditional mechanic persistence adapter', () => {
population: 'allies',
radiusMeters: 40,
},
probabilityPercent: 29,
variants: {
I: { value: 240 },
II: { value: 336 },
@@ -263,12 +267,18 @@ describe('conditional mechanic persistence adapter', () => {
{
key: 'target-detonation',
label: 'Target Detonation',
phase: 'on_exit',
phase: 'after_event',
kind: 'direct_damage',
scope: 'target',
basis: 'flat',
statePool: 'marked-target',
perStack: true,
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
},
variants: {
I: {
value: 240,
@@ -322,6 +332,23 @@ describe('conditional mechanic persistence adapter', () => {
mutations: [],
outcomes: [],
},
{
key: 'tactical-link-assist',
label: 'Tactical Link assist',
event: 'assist',
if: {
kind: 'compare',
left: {
source: 'event',
path: 'values.assignedAbilityActive',
},
operator: 'eq',
right: true,
},
reducer: { kind: 'none' },
mutations: [],
outcomes: [],
},
],
},
);
@@ -366,11 +393,23 @@ describe('conditional mechanic persistence adapter', () => {
population: 'allies',
radiusMeters: 40,
},
probabilityPercent: 29,
variants: {
I: { value: 240 },
II: { value: 336 },
},
});
expect(loaded?.clauses[0].outcomes[1]).toMatchObject({
phase: 'after_event',
kind: 'direct_damage',
scope: 'target',
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
},
});
expect(loaded?.clauses[0].if).toEqual({
kind: 'compare',
left: {
@@ -381,6 +420,10 @@ describe('conditional mechanic persistence adapter', () => {
operator: 'lt',
right: 75,
});
expect(loaded?.clauses[0]).toMatchObject({
procChancePercent: 29,
requiresAssignedAbilityActive: true,
});
expect(loaded?.pools[0]).toMatchObject({
key: 'marked-target',
stateKind: 'target_mark',
@@ -399,7 +442,7 @@ describe('conditional mechanic persistence adapter', () => {
},
]);
expect(loaded?.clauses[0].outcomes[1]).toMatchObject({
phase: 'on_exit',
phase: 'after_event',
kind: 'direct_damage',
scope: 'target',
statePool: 'marked-target',
@@ -430,5 +473,14 @@ describe('conditional mechanic persistence adapter', () => {
right: true,
},
});
expect(loaded?.clauses[2]).toMatchObject({
event: 'assist',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.assignedAbilityActive' },
operator: 'eq',
right: true,
},
});
});
});
+32 -3
View File
@@ -90,6 +90,7 @@ const outcomeMetadataColumns = [
'delivery_mode',
'delivery_duration_seconds',
'targeting_json',
'probability_percent',
'state_pool_id',
'min_state_value',
'max_state_value',
@@ -205,6 +206,10 @@ export function conditionalRuleDraftFromRows(
.filter((row) => row.rule_id === rule.id && Number(row.active ?? 1) === 1)
.sort((left, right) => Number(left.sort_order) - Number(right.sort_order))
.map((row): ConditionalMechanicClauseDraft => {
const parameters = parseJson<Record<string, unknown>>(
row.recipe_parameters_json,
{},
);
const grouped = new Map<string, OutcomeRow[]>();
for (const outcome of rowsByClause.get(String(row.id)) ?? []) {
const key = String(outcome.effect_key);
@@ -268,6 +273,10 @@ export function conditionalRuleDraftFromRows(
...(first.targeting_json
? { targeting: parseJson(first.targeting_json, undefined) }
: {}),
...(first.probability_percent === null ||
first.probability_percent === undefined
? {}
: { probabilityPercent: Number(first.probability_percent) }),
...(first.stat_attribute_id
? { attributeId: String(first.stat_attribute_id) }
: {}),
@@ -329,6 +338,15 @@ export function conditionalRuleDraftFromRows(
),
}
: {}),
...(typeof parameters.procChancePercent === 'number'
? { procChancePercent: parameters.procChancePercent }
: {}),
...(typeof parameters.requiresAssignedAbilityActive === 'boolean'
? {
requiresAssignedAbilityActive:
parameters.requiresAssignedAbilityActive,
}
: {}),
...(row.cooldown_seconds === null || row.cooldown_seconds === undefined
? {}
: { cooldownSeconds: Number(row.cooldown_seconds) }),
@@ -495,7 +513,17 @@ export async function saveConditionalRuleInTransaction(
clause.label || clause.key,
clause.event,
clause.recipeKey ?? null,
'{}',
JSON.stringify({
...(clause.procChancePercent === undefined
? {}
: { procChancePercent: clause.procChancePercent }),
...(clause.requiresAssignedAbilityActive === undefined
? {}
: {
requiresAssignedAbilityActive:
clause.requiresAssignedAbilityActive,
}),
}),
JSON.stringify(clause.if ?? { kind: 'true' }),
JSON.stringify(clause.reducer),
clause.weight === undefined ? null : JSON.stringify(clause.weight),
@@ -518,9 +546,9 @@ export async function saveConditionalRuleInTransaction(
application_kind,effect_scope,stat_attribute_id,value,value_expression_json,
value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing,
resource_kind,delivery_mode,delivery_duration_seconds,
targeting_json,min_state_value,max_state_value,description_slot,sort_order,
targeting_json,probability_percent,min_state_value,max_state_value,description_slot,sort_order,
created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
randomUUID(),
ruleId,
@@ -552,6 +580,7 @@ export async function saveConditionalRuleInTransaction(
outcome.targeting === undefined
? null
: JSON.stringify(outcome.targeting),
outcome.probabilityPercent ?? null,
outcome.minStacks ?? null,
outcome.maxStacks ?? null,
outcome.descriptionSlot ?? null,
+20 -3
View File
@@ -37,14 +37,31 @@ const proposalResultKeys: Partial<
saveAugmentProfile: { saved: 'augmentProfileSaved' },
},
weapons: { save: { saved: 'weaponSaved', mode: 'weaponMode' } },
weaponmods: { save: { saved: 'weaponModSaved', mode: 'weaponModMode' } },
gearmods: { save: { saved: 'gearModSaved', mode: 'gearModMode' } },
weaponmods: {
save: { saved: 'weaponModSaved', mode: 'weaponModMode' },
saveSlotIcon: { saved: 'weaponModSlotIconSaved' },
saveSecondaryPool: {
saved: 'weaponModSecondaryPoolSaved',
mode: 'weaponModSecondaryPoolMode',
},
},
gearmods: {
save: { saved: 'gearModSaved', mode: 'gearModMode' },
saveSecondaryPool: {
saved: 'gearModSecondaryPoolSaved',
mode: 'gearModSecondaryPoolMode',
},
},
talents: { saveTalent: { saved: 'talentSaved', mode: 'talentMode' } },
protocols: {
save: { saved: 'protocolSaved', mode: 'protocolMode' },
saveProfile: { saved: 'profileSaved' },
},
skillchips: { save: { saved: 'skillChipSaved', mode: 'skillChipMode' } },
skillchips: {
save: { saved: 'skillChipSaved', mode: 'skillChipMode' },
savePrimaryProfile: { saved: 'skillChipPrimaryProfileSaved' },
saveSecondaryPool: { saved: 'skillChipSecondaryPoolSaved' },
},
specializations: {
saveProgression: { saved: 'progressionSaved' },
saveSpecialization: {
+12 -1
View File
@@ -7,6 +7,7 @@ import {
BackgroundRemovalError,
removeBackgroundPreview,
} from './remove-background';
import { weaponModSlotDefinitionById } from '$lib/weapons/mod-slots';
export const GAME_ICON_TYPES = [
'talent',
@@ -16,6 +17,7 @@ export const GAME_ICON_TYPES = [
'specialization',
'specialization_ability',
'specialization_node',
'weapon_mod_slot',
] as const;
export type GameDataIconType = (typeof GAME_ICON_TYPES)[number];
export const MAX_GAME_ICON_BYTES = 2 * 1024 * 1024;
@@ -23,7 +25,7 @@ export const MAX_GAME_ICON_PIXELS = 12_000_000;
export const GAME_ICON_SIZE = 256;
const ENTITY_ID_PATTERN = /^[0-9a-f-]{36}$/i;
const STORAGE_KEY_PATTERN =
/^(talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
/^(talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i;
const LIBRARY_CATEGORY: Record<GameDataIconType, string> = {
talent: 'Talents',
brand_set: 'Brands',
@@ -32,6 +34,7 @@ const LIBRARY_CATEGORY: Record<GameDataIconType, string> = {
specialization: 'Specializations',
specialization_ability: 'Specializations',
specialization_node: 'Specializations',
weapon_mod_slot: 'Weapon Mods',
};
const LIBRARY_LABEL: Record<GameDataIconType, string> = {
talent: 'Talent',
@@ -41,6 +44,7 @@ const LIBRARY_LABEL: Record<GameDataIconType, string> = {
specialization: 'Specialization',
specialization_ability: 'Specialization Ability',
specialization_node: 'Specialization Tree Node',
weapon_mod_slot: 'Weapon Mod Slot',
};
export class GameDataIconValidationError extends Error {}
@@ -255,6 +259,13 @@ async function libraryMetadata(
],
};
}
case 'weapon_mod_slot': {
const definition = weaponModSlotDefinitionById(entityId);
return {
name: definition ? `${definition.name} Slot` : null,
tags: ['weapon mod', 'slot', definition?.name],
};
}
}
return { name: null, tags: [] };
}
+167 -2
View File
@@ -1,17 +1,24 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import sharp from 'sharp';
const { rmMock } = vi.hoisted(() => ({ rmMock: vi.fn() }));
const { mkdirMock, rmMock, writeFileMock } = vi.hoisted(() => ({
mkdirMock: vi.fn(),
rmMock: vi.fn(),
writeFileMock: vi.fn(),
}));
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof import('node:fs/promises')>()),
mkdir: mkdirMock,
rm: rmMock,
writeFile: writeFileMock,
}));
import { GAME_ICON_TYPES } from './game-data-icons';
import {
GAME_DATA_RENDER_TYPES,
ManagedImageDeletionError,
applyGameDataRender,
deleteManagedImage,
processManagedImage,
} from './managed-images';
@@ -26,6 +33,16 @@ class FakeDatabase {
prepare(sql: string) {
return Promise.resolve({
get: async (...args: unknown[]) => {
if (
sql.startsWith('SELECT * FROM managed_images') &&
sql.includes('source_entity_type')
) {
return [...this.images.values()].find(
(row) =>
row.source_entity_type === args[0] &&
row.source_entity_id === args[1],
);
}
if (sql.startsWith('SELECT * FROM managed_images'))
return this.images.get(String(args[0]));
if (sql.startsWith('SELECT * FROM game_data_icons')) {
@@ -42,7 +59,64 @@ class FakeDatabase {
}
if (sql.startsWith('DELETE FROM managed_images')) {
const row = this.images.get(String(args[0]));
if (row?.storage_key === args[1]) this.images.delete(String(args[0]));
if (!sql.includes('storage_key') || row?.storage_key === args[1])
this.images.delete(String(args[0]));
return;
}
if (sql.startsWith('UPDATE managed_images SET')) {
const row = this.images.get(String(args[7]));
if (!row) throw new Error('Missing managed image for update');
this.images.set(String(args[7]), {
...row,
category: args[0],
tags_json: args[1],
is_icon: 0,
force_white: 0,
original_filename: args[2],
byte_size: args[3],
width: args[4],
height: args[5],
updated_at: args[6],
});
return;
}
if (sql.startsWith('INSERT INTO managed_images')) {
const [
id,
name,
category,
tagsJson,
isIcon,
forceWhite,
storageKey,
originalFilename,
byteSize,
width,
height,
sourceType,
sourceEntityId,
createdBy,
createdAt,
updatedAt,
] = args;
this.images.set(String(id), {
id,
name,
category,
tags_json: tagsJson,
is_icon: isIcon,
force_white: forceWhite,
storage_key: storageKey,
original_filename: originalFilename,
byte_size: byteSize,
width,
height,
source_entity_type: sourceType,
source_entity_id: sourceEntityId,
created_by: createdBy,
created_at: createdAt,
updated_at: updatedAt,
});
return;
}
throw new Error(`Unexpected run: ${sql}`);
@@ -210,3 +284,94 @@ describe('processManagedImage', () => {
expect(metadata.height).toBe(1000);
});
});
describe('applyGameDataRender', () => {
beforeEach(() => {
mkdirMock.mockReset().mockResolvedValue(undefined);
rmMock.mockReset().mockResolvedValue(undefined);
writeFileMock.mockReset().mockResolvedValue(undefined);
});
it.each(GAME_DATA_RENDER_TYPES)(
'persists and returns authoritative %s render metadata',
async (type) => {
const database = new FakeDatabase();
const result = await applyGameDataRender(
database as never,
type,
sourceId,
'Test Entity',
{
file: new File(['source'], 'source.png', { type: 'image/png' }),
remove: false,
processed: {
output: Buffer.from('processed-render'),
width: 1000,
height: 1000,
},
},
'operator-1',
);
expect(result).toMatchObject({
original_filename: 'source.png',
byte_size: Buffer.byteLength('processed-render'),
width: 1000,
height: 1000,
source_entity_type: `${type}_render`,
source_entity_id: sourceId,
created_by: 'operator-1',
created_at: expect.any(String),
updated_at: expect.any(String),
});
expect(writeFileMock).toHaveBeenCalledOnce();
},
);
it('returns refreshed replacement metadata without rewriting creator provenance', async () => {
const database = new FakeDatabase();
database.images.set(imageId, {
id: imageId,
name: 'Ability render',
category: 'Specializations',
tags_json: '[]',
is_icon: 0,
force_white: 0,
storage_key: `library-${imageId}.webp`,
original_filename: 'old.png',
byte_size: 3,
width: 100,
height: 100,
source_entity_type: 'specialization_ability_render',
source_entity_id: sourceId,
created_by: 'original-operator',
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
});
const result = await applyGameDataRender(
database as never,
'specialization_ability',
sourceId,
'Ability',
{
file: new File(['new'], 'new.png', { type: 'image/png' }),
remove: false,
processed: {
output: Buffer.from('replacement'),
width: 1000,
height: 1000,
},
},
'replacement-operator',
);
expect(result).toMatchObject({
id: imageId,
original_filename: 'new.png',
byte_size: Buffer.byteLength('replacement'),
created_by: 'original-operator',
created_at: '2026-01-01T00:00:00.000Z',
});
});
});
+12 -26
View File
@@ -41,7 +41,7 @@ export type GameDataRenderType = (typeof GAME_DATA_RENDER_TYPES)[number];
const MAX_PIXELS = 24_000_000;
const ID_PATTERN = /^[0-9a-f-]{36}$/i;
const KEY_PATTERN =
/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i;
export class ManagedImageValidationError extends Error {}
export class ManagedImageDeletionError extends Error {}
@@ -250,7 +250,7 @@ export async function applyGameDataRender(
);
await (
await database.prepare(`UPDATE managed_images SET category=?,tags_json=?,is_icon=0,force_white=0,
original_filename=?,byte_size=?,width=?,height=?,created_by=?,updated_at=? WHERE id=?`)
original_filename=?,byte_size=?,width=?,height=?,updated_at=? WHERE id=?`)
).run(
category[type],
tags,
@@ -258,19 +258,14 @@ export async function applyGameDataRender(
processed.output.length,
processed.width,
processed.height,
actorId,
now,
existing.id,
);
return {
...existing,
category: category[type],
tags_json: tags,
byte_size: processed.output.length,
width: processed.width,
height: processed.height,
updated_at: now,
};
return (
(await (
await database.prepare('SELECT * FROM managed_images WHERE id=?')
).get(existing.id)) ?? null
);
}
const id = randomUUID();
const storageKey = await saveManagedImageFile(id, processed.output);
@@ -298,20 +293,11 @@ export async function applyGameDataRender(
now,
now,
);
return {
id,
name,
category: category[type],
tags_json: tags,
is_icon: 0,
storage_key: storageKey,
byte_size: processed.output.length,
width: processed.width,
height: processed.height,
source_entity_type: sourceType,
source_entity_id: entityId,
updated_at: now,
};
return (
(await (
await database.prepare('SELECT * FROM managed_images WHERE id=?')
).get(id)) ?? null
);
}
export async function deleteManagedImage(database: Database, id: string) {
+100
View File
@@ -0,0 +1,100 @@
import type { Database } from '@tursodatabase/database';
import {
MOD_SECONDARY_QUALITIES,
normalizeModSecondaryPool,
type ModSecondaryRange,
} from '$lib/mods/secondary-pool';
export type ModSecondaryPoolKind = 'weapon' | 'gear';
function tables(kind: ModSecondaryPoolKind) {
return {
options: `${kind}_mod_secondary_options`,
values: `${kind}_mod_secondary_quality_values`,
};
}
export async function getModSecondaryPool(
database: Database,
kind: ModSecondaryPoolKind,
): Promise<ModSecondaryRange[]> {
const table = tables(kind);
const [options, values] = await Promise.all([
(
await database.prepare(`SELECT stat_attribute_id FROM ${table.options}
ORDER BY stat_attribute_id`)
).all(),
(
await database.prepare(`SELECT * FROM ${table.values}
ORDER BY stat_attribute_id,quality`)
).all(),
]);
const pool = options.map((option) => {
const optionValues = values.filter(
(value) => value.stat_attribute_id === option.stat_attribute_id,
);
if (
optionValues.length !== MOD_SECONDARY_QUALITIES.length ||
MOD_SECONDARY_QUALITIES.some(
(quality) => !optionValues.some((value) => value.quality === quality),
)
) {
throw new Error(
`${kind} Mod secondary Attribute ${String(option.stat_attribute_id)} does not have a complete quality profile.`,
);
}
return {
statId: String(option.stat_attribute_id),
values: Object.fromEntries(
optionValues.map((value) => [
String(value.quality),
{
level1Min: Number(value.level_1_min),
level1Max: Number(value.level_1_max),
level20Min: Number(value.level_20_min),
level20Max: Number(value.level_20_max),
},
]),
) as ModSecondaryRange['values'],
};
});
if (!pool.length) return [];
const normalized = normalizeModSecondaryPool(pool);
if (normalized.error) {
throw new Error(`${kind} Mod secondary pool: ${normalized.error}`);
}
return normalized.pool;
}
export async function replaceModSecondaryPool(
database: Database,
kind: ModSecondaryPoolKind,
pool: ModSecondaryRange[],
) {
const table = tables(kind);
await database.exec(`DELETE FROM ${table.values}`);
await database.exec(`DELETE FROM ${table.options}`);
for (const range of pool) {
await (
await database.prepare(
`INSERT INTO ${table.options}(stat_attribute_id) VALUES(?)`,
)
).run(range.statId);
for (const quality of MOD_SECONDARY_QUALITIES) {
const values = range.values[quality];
await (
await database.prepare(`INSERT INTO ${table.values}
(stat_attribute_id,quality,level_1_min,level_1_max,level_20_min,level_20_max)
VALUES(?,?,?,?,?,?)`)
).run(
range.statId,
quality,
values.level1Min,
values.level1Max,
values.level20Min,
values.level20Max,
);
}
}
}
@@ -0,0 +1,96 @@
import { connect } from '@tursodatabase/database';
import { afterEach, describe, expect, it } from 'vitest';
import {
getOsProtocolProfileAttributeIds,
type OsProtocolAttributeType,
} from './os-protocol-profiles';
const databases: Array<Awaited<ReturnType<typeof connect>>> = [];
afterEach(async () => {
await Promise.all(databases.splice(0).map((database) => database.close()));
});
async function databaseWithAttributes() {
const database = await connect(':memory:');
databases.push(database);
await database.exec(`CREATE TABLE stat_attributes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
unit TEXT NOT NULL,
active INTEGER NOT NULL
)`);
for (const [id, name, unit] of [
['firepower-flat', 'Firepower', ''],
['firepower-percent', 'Firepower', '%'],
['toughness-flat', 'Toughness', ''],
['toughness-percent', 'Toughness', '%'],
['engineering-flat', 'Engineering', ''],
['weapon-damage-flat', 'Weapon Damage', ''],
['weapon-damage-percent', 'Weapon Damage', '%'],
['skill-damage-percent', 'Skill Damage', '%'],
['health-flat', 'Health', ''],
]) {
await (
await database.prepare(
'INSERT INTO stat_attributes(id,name,unit,active) VALUES(?,?,?,1)',
)
).run(id, name, unit);
}
return database;
}
describe('OS Protocol profile Attributes', () => {
it.each([
[
'Firepower',
{
Core: 'firepower-flat',
Damage: 'weapon-damage-flat',
Health: 'health-flat',
},
],
[
'Toughness',
{
Core: 'toughness-flat',
Damage: 'weapon-damage-flat',
Health: 'health-flat',
},
],
[
'Engineering',
{
Core: 'engineering-flat',
Damage: 'skill-damage-percent',
Health: 'health-flat',
},
],
] as const)('resolves the exact %s role rows', async (type, expected) => {
const database = await databaseWithAttributes();
await expect(
getOsProtocolProfileAttributeIds(
database,
type as OsProtocolAttributeType,
),
).resolves.toEqual(expected);
});
it('reports a missing required role without selecting a similarly named row', async () => {
const database = await databaseWithAttributes();
await (
await database.prepare(
"DELETE FROM stat_attributes WHERE id='weapon-damage-flat'",
)
).run();
await expect(
getOsProtocolProfileAttributeIds(database, 'Firepower'),
).resolves.toEqual({
Core: 'firepower-flat',
Damage: null,
Health: 'health-flat',
});
});
});
+48
View File
@@ -0,0 +1,48 @@
import type { Database } from '@tursodatabase/database';
export const OS_PROTOCOL_PROFILE_ATTRIBUTE_SPECS = {
Firepower: {
Core: { name: 'Firepower', unit: '' },
Damage: { name: 'Weapon Damage', unit: '' },
Health: { name: 'Health', unit: '' },
},
Toughness: {
Core: { name: 'Toughness', unit: '' },
Damage: { name: 'Weapon Damage', unit: '' },
Health: { name: 'Health', unit: '' },
},
Engineering: {
Core: { name: 'Engineering', unit: '' },
Damage: { name: 'Skill Damage', unit: '%' },
Health: { name: 'Health', unit: '' },
},
} as const;
export type OsProtocolAttributeType =
keyof typeof OS_PROTOCOL_PROFILE_ATTRIBUTE_SPECS;
export type OsProtocolSecondaryRole = 'Core' | 'Damage' | 'Health';
export async function getOsProtocolProfileAttributeIds(
database: Database,
attributeType: OsProtocolAttributeType,
) {
const specs = OS_PROTOCOL_PROFILE_ATTRIBUTE_SPECS[attributeType];
const entries = await Promise.all(
(
Object.entries(specs) as Array<
[OsProtocolSecondaryRole, { name: string; unit: string }]
>
).map(async ([role, spec]) => {
const row = await (
await database.prepare(
'SELECT id FROM stat_attributes WHERE active=1 AND name=? COLLATE NOCASE AND unit=?',
)
).get(spec.name, spec.unit);
return [role, row ? String(row.id) : null] as const;
}),
);
return Object.fromEntries(entries) as Record<
OsProtocolSecondaryRole,
string | null
>;
}
+70
View File
@@ -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 [
@@ -15,7 +16,12 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
talentEffects,
talentTemplates,
chipSets,
abilitySkillChipLinks,
chipTwoPiece,
chipPrimaryOptions,
chipPrimaryQualityValues,
chipSecondaryOptions,
chipSecondaryQualityValues,
chipTalents,
chipTalentEffects,
protocols,
@@ -26,6 +32,8 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
protocolTalentEffects,
specializations,
specializationFocuses,
specializationAbilities,
focusAbilities,
specializationNodes,
specializationRanks,
specializationRankEffects,
@@ -80,9 +88,29 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
'SELECT * FROM skill_chip_sets ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
`SELECT link.ability_id,link.compatible_ability_id
FROM specialization_ability_skill_chip_links link
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
JOIN specialization_abilities compatible ON compatible.id=link.compatible_ability_id AND compatible.active=1`,
)
).all(),
(
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
).all(),
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
(
await database.prepare('SELECT * FROM skill_chip_primary_quality_values')
).all(),
(
await database.prepare('SELECT * FROM skill_chip_secondary_options')
).all(),
(
await database.prepare(
'SELECT * FROM skill_chip_secondary_quality_values',
)
).all(),
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
(
await database.prepare(
@@ -119,6 +147,16 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
'SELECT * FROM specialization_focuses ORDER BY specialization_id,display_order,name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_abilities ORDER BY specialization_id,name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_focus_abilities ORDER BY focus_id,ability_slot',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_tree_nodes ORDER BY focus_id,row_number,column_number',
@@ -161,12 +199,32 @@ 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,
);
return {
...set,
compatibleAbilityIds: [
String(set.specialization_ability_id),
...abilitySkillChipLinks.flatMap((link) =>
link.ability_id === set.specialization_ability_id
? [String(link.compatible_ability_id)]
: link.compatible_ability_id === set.specialization_ability_id
? [String(link.ability_id)]
: [],
),
],
twoPiece:
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
null,
@@ -215,6 +273,18 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
.filter((focus) => focus.specialization_id === specialization.id)
.map((focus) => ({
...focus,
abilities: focusAbilities
.filter((link) => link.focus_id === focus.id)
.sort(
(left, right) =>
Number(left.ability_slot) - Number(right.ability_slot),
)
.map((link) =>
specializationAbilities.find(
(ability) => ability.id === link.ability_id,
),
)
.filter(Boolean),
nodes: specializationNodes
.filter((node) => node.focus_id === focus.id)
.map((node) => ({
+1 -1
View File
@@ -59,7 +59,7 @@ export async function loadSharedBuildProjection(
).all(),
(
await database.prepare(
'SELECT id,name,category,is_exotic,rpm,magazine,reload_speed,damage_type FROM weapons',
'SELECT id,name,category,is_exotic,rpm,magazine,reload_speed,damage_type,exotic_talent_id FROM weapons',
)
).all(),
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
+117
View File
@@ -0,0 +1,117 @@
import type { Database } from '@tursodatabase/database';
import {
SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES,
SKILL_CHIP_QUALITIES,
SKILL_CHIP_SECONDARY_QUALITIES,
type SkillChipProfile,
} from '$lib/skill-chips/profiles';
export async function getSkillChipPrimaryAttributeIds(database: Database) {
const rows = await (
await database.prepare(
`SELECT id,name FROM stat_attributes
WHERE active=1 AND name IN (${SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES.map(() => '?').join(',')})
ORDER BY name COLLATE NOCASE`,
)
).all(...SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES);
if (rows.length !== SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES.length)
throw new Error(
'All four fixed Skill Chip primary Attributes must be active.',
);
return rows.map((row) => String(row.id));
}
async function getProfiles(
database: Database,
optionTable: string,
valueTable: string,
) {
const [options, values] = await Promise.all([
(
await database.prepare(
`SELECT stat_attribute_id FROM ${optionTable} ORDER BY stat_attribute_id`,
)
).all(),
(
await database.prepare(
`SELECT * FROM ${valueTable} ORDER BY stat_attribute_id,quality`,
)
).all(),
]);
if (!options.length) return [];
return options.map((option) => ({
statId: String(option.stat_attribute_id),
values: Object.fromEntries(
values
.filter((value) => value.stat_attribute_id === option.stat_attribute_id)
.map((value) => [
String(value.quality),
{ min: Number(value.min_value), max: Number(value.max_value) },
]),
),
}));
}
export const getSkillChipPrimaryProfiles = (database: Database) =>
getProfiles(
database,
'skill_chip_primary_options',
'skill_chip_primary_quality_values',
);
export const getSkillChipSecondaryPool = (database: Database) =>
getProfiles(
database,
'skill_chip_secondary_options',
'skill_chip_secondary_quality_values',
);
async function replaceProfiles(
database: Database,
optionTable: string,
valueTable: string,
profiles: SkillChipProfile[],
qualities: readonly string[],
) {
await database.exec(`DELETE FROM ${valueTable}`);
await database.exec(`DELETE FROM ${optionTable}`);
for (const profile of profiles) {
await (
await database.prepare(
`INSERT INTO ${optionTable}(stat_attribute_id) VALUES(?)`,
)
).run(profile.statId);
for (const quality of qualities) {
const range = profile.values[quality];
await (
await database.prepare(
`INSERT INTO ${valueTable}(stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?)`,
)
).run(profile.statId, quality, range.min, range.max);
}
}
}
export const replaceSkillChipPrimaryProfiles = (
database: Database,
profiles: SkillChipProfile[],
) =>
replaceProfiles(
database,
'skill_chip_primary_options',
'skill_chip_primary_quality_values',
profiles,
SKILL_CHIP_QUALITIES,
);
export const replaceSkillChipSecondaryPool = (
database: Database,
profiles: SkillChipProfile[],
) =>
replaceProfiles(
database,
'skill_chip_secondary_options',
'skill_chip_secondary_quality_values',
profiles,
SKILL_CHIP_SECONDARY_QUALITIES,
);
+39 -24
View File
@@ -17,37 +17,51 @@ export async function refreshSpecializationFocusReady(
abilityCount: 0,
chipReadyCount: 0,
};
const [config, abilityCountRow, chipReadyRow, nodes, rankProfiles] =
await Promise.all([
(
await database.prepare(
"SELECT xp_points,shd_cache_points FROM specialization_progression_config WHERE id='default'",
)
).get(),
(
await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
const [
config,
abilityCountRow,
profileRow,
chipReadyRow,
nodes,
rankProfiles,
] = await Promise.all([
(
await database.prepare(
"SELECT xp_points,shd_cache_points FROM specialization_progression_config WHERE id='default'",
)
).get(),
(
await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
JOIN specialization_abilities ability ON ability.id=link.ability_id
WHERE link.focus_id=? AND ability.active=1`)
).get(focusId),
(
await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
).get(focusId),
(
await database.prepare(`SELECT
(SELECT COUNT(*) FROM skill_chip_primary_options)=4
AND (SELECT COUNT(*) FROM skill_chip_primary_quality_values)=16
AND (SELECT COUNT(*) FROM skill_chip_secondary_options)>=2
AND (SELECT COUNT(*) FROM skill_chip_secondary_quality_values)=
(SELECT COUNT(*)*3 FROM skill_chip_secondary_options) AS ready`)
).get(),
(
await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
WHERE link.focus_id=? AND (
SELECT COUNT(*) FROM skill_chip_sets chips
WHERE chips.specialization_ability_id=ability.id AND chips.active=1
)=3`)
).get(focusId),
(
await database.prepare(
'SELECT row_number,max_rank FROM specialization_tree_nodes WHERE focus_id=? AND active=1 ORDER BY row_number,column_number',
)
).all(focusId),
(
await database.prepare(
'SELECT rank,point_cost FROM specialization_rank_cost_profiles ORDER BY rank',
)
).all(),
]);
).get(focusId),
(
await database.prepare(
'SELECT row_number,max_rank FROM specialization_tree_nodes WHERE focus_id=? AND active=1 ORDER BY row_number,column_number',
)
).all(focusId),
(
await database.prepare(
'SELECT rank,point_cost FROM specialization_rank_cost_profiles ORDER BY rank',
)
).all(),
]);
const pointForRank = new Map(
rankProfiles.map((profile) => [
Number(profile.rank),
@@ -69,6 +83,7 @@ export async function refreshSpecializationFocusReady(
Number(focus.active) === 1 &&
abilityCount === 3 &&
chipReadyCount === 3 &&
Number(profileRow?.ready) === 1 &&
nodes.length > 0 &&
contiguousRows &&
pointTotal === availablePoints;
+179
View File
@@ -0,0 +1,179 @@
import { describe, expect, it } from 'vitest';
import {
normalizeSkillChipProfiles,
SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES,
SKILL_CHIP_QUALITIES,
SKILL_CHIP_SECONDARY_CAPACITY,
SKILL_CHIP_SECONDARY_QUALITIES,
} from './profiles';
const primaryIds = [
'skill-duration',
'skill-radius',
'skill-cooldown-recovery',
'skill-intensity',
] as const;
const ranges = (qualities: readonly string[], offset = 0) =>
Object.fromEntries(
qualities.map((quality, index) => [
quality,
{ min: String(offset + index + 1), max: offset + index + 2 },
]),
);
const profile = (statId: string, qualities: readonly string[], offset = 0) => ({
statId,
values: ranges(qualities, offset),
});
describe('Skill Chip shared profiles', () => {
it('defines the exact four fixed primary Attributes and all four qualities', () => {
expect(SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES).toEqual([
'Skill Duration',
'Skill Radius',
'Skill Cooldown Recovery',
'Skill Intensity',
]);
expect(SKILL_CHIP_QUALITIES).toEqual([
'Standard',
'Specialized',
'Superior',
'High-end',
]);
});
it('defines the 0/1/2/2 secondary capacity mapping', () => {
expect(SKILL_CHIP_SECONDARY_CAPACITY).toEqual({
Standard: 0,
Specialized: 1,
Superior: 2,
'High-end': 2,
});
});
it('normalizes exactly the four fixed primary profiles across every quality', () => {
const raw = primaryIds.map((statId, index) =>
profile(` ${statId} `, SKILL_CHIP_QUALITIES, index * 10),
);
const result = normalizeSkillChipProfiles(raw, SKILL_CHIP_QUALITIES, {
expectedStatIds: primaryIds,
minimumOptions: 4,
});
expect(result.error).toBeNull();
expect(result.profiles).toHaveLength(4);
expect(result.profiles.map(({ statId }) => statId)).toEqual(primaryIds);
expect(result.profiles[0]).toEqual({
statId: 'skill-duration',
values: {
Standard: { min: 1, max: 2 },
Specialized: { min: 2, max: 3 },
Superior: { min: 3, max: 4 },
'High-end': { min: 4, max: 5 },
},
});
});
it('rejects a primary profile that does not contain the exact fixed set', () => {
const raw = primaryIds.map((statId) =>
profile(statId, SKILL_CHIP_QUALITIES),
);
raw[3] = profile('weapon-damage', SKILL_CHIP_QUALITIES);
expect(
normalizeSkillChipProfiles(raw, SKILL_CHIP_QUALITIES, {
expectedStatIds: primaryIds,
minimumOptions: 4,
}),
).toEqual({
profiles: [],
error:
'The primary profile must contain the four fixed Skill Attributes.',
});
});
it('normalizes secondary profiles across only the three supported qualities', () => {
expect(SKILL_CHIP_SECONDARY_QUALITIES).toEqual([
'Specialized',
'Superior',
'High-end',
]);
const result = normalizeSkillChipProfiles(
[
profile(' critical-hit-chance ', SKILL_CHIP_SECONDARY_QUALITIES),
profile('critical-hit-damage', SKILL_CHIP_SECONDARY_QUALITIES, 10),
],
SKILL_CHIP_SECONDARY_QUALITIES,
{ minimumOptions: 2 },
);
expect(result.error).toBeNull();
expect(result.profiles[0]).toEqual({
statId: 'critical-hit-chance',
values: {
Specialized: { min: 1, max: 2 },
Superior: { min: 2, max: 3 },
'High-end': { min: 3, max: 4 },
},
});
expect(result.profiles[0].values).not.toHaveProperty('Standard');
});
it('rejects duplicate Attributes after trimming IDs', () => {
const result = normalizeSkillChipProfiles(
[
profile('critical-hit-chance', SKILL_CHIP_SECONDARY_QUALITIES),
profile(' critical-hit-chance ', SKILL_CHIP_SECONDARY_QUALITIES),
],
SKILL_CHIP_SECONDARY_QUALITIES,
{ minimumOptions: 2 },
);
expect(result).toEqual({
profiles: [],
error: 'Choose a unique Attribute for every shared option.',
});
});
it.each([
[
'missing ranges',
{
Specialized: { min: 1, max: 2 },
Superior: { min: 2, max: 3 },
},
'High-end requires a minimum and maximum.',
],
[
'reversed ranges',
{
Specialized: { min: 2, max: 1 },
Superior: { min: 2, max: 3 },
'High-end': { min: 3, max: 4 },
},
'Specialized requires a valid non-negative range.',
],
[
'negative ranges',
{
Specialized: { min: -1, max: 2 },
Superior: { min: 2, max: 3 },
'High-end': { min: 3, max: 4 },
},
'Specialized requires a valid non-negative range.',
],
])('rejects %s', (_label, values, error) => {
const result = normalizeSkillChipProfiles(
[
{ statId: 'critical-hit-chance', values },
profile('critical-hit-damage', SKILL_CHIP_SECONDARY_QUALITIES),
],
SKILL_CHIP_SECONDARY_QUALITIES,
{ minimumOptions: 2 },
);
expect(result).toEqual({ profiles: [], error });
});
});
+127
View File
@@ -0,0 +1,127 @@
export const SKILL_CHIP_QUALITIES = [
'Standard',
'Specialized',
'Superior',
'High-end',
] as const;
export const SKILL_CHIP_SECONDARY_QUALITIES = [
'Specialized',
'Superior',
'High-end',
] as const;
export const SKILL_CHIP_SECONDARY_CAPACITY = {
Standard: 0,
Specialized: 1,
Superior: 2,
'High-end': 2,
} as const satisfies Record<(typeof SKILL_CHIP_QUALITIES)[number], number>;
export const SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES = [
'Skill Duration',
'Skill Radius',
'Skill Cooldown Recovery',
'Skill Intensity',
] as const;
export type SkillChipRange = { min: number; max: number };
export type SkillChipProfile = {
statId: string;
values: Record<string, SkillChipRange>;
};
export function hydrateSkillChipProfileRows(
options: Array<Record<string, unknown>>,
qualityValues: Array<Record<string, unknown>>,
) {
return options.map((option) => ({
statId: String(option.stat_attribute_id),
ranges: Object.fromEntries(
qualityValues
.filter((range) => range.stat_attribute_id === option.stat_attribute_id)
.map((range) => [
String(range.quality),
{ min: Number(range.min_value), max: Number(range.max_value) },
]),
),
}));
}
const finite = (value: unknown) => {
if (value === '' || value === null || value === undefined) return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
};
export function normalizeSkillChipProfiles(
raw: unknown,
qualities: readonly string[],
options: {
expectedStatIds?: readonly string[];
minimumOptions?: number;
} = {},
): { profiles: SkillChipProfile[]; error: string | null } {
if (!Array.isArray(raw))
return { profiles: [], error: 'The shared profile payload is invalid.' };
if (raw.length < (options.minimumOptions ?? 1) || raw.length > 50)
return {
profiles: [],
error: `Configure ${options.minimumOptions ?? 1}–50 shared Attribute options.`,
};
const profiles: SkillChipProfile[] = [];
const seen = new Set<string>();
for (const entry of raw) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
return { profiles: [], error: 'A shared Attribute option is invalid.' };
const candidate = entry as Record<string, unknown>;
const statId = String(candidate.statId ?? '').trim();
if (!statId || seen.has(statId))
return {
profiles: [],
error: 'Choose a unique Attribute for every shared option.',
};
seen.add(statId);
const rawValues = candidate.values;
if (!rawValues || typeof rawValues !== 'object' || Array.isArray(rawValues))
return {
profiles: [],
error: 'Every shared Attribute needs quality ranges.',
};
const values: Record<string, SkillChipRange> = {};
for (const quality of qualities) {
const rawRange = (rawValues as Record<string, unknown>)[quality];
if (!rawRange || typeof rawRange !== 'object' || Array.isArray(rawRange))
return {
profiles: [],
error: `${quality} requires a minimum and maximum.`,
};
const range = rawRange as Record<string, unknown>;
const min = finite(range.min);
const max = finite(range.max);
if (min === null || max === null || min < 0 || max < min)
return {
profiles: [],
error: `${quality} requires a valid non-negative range.`,
};
values[quality] = { min, max };
}
profiles.push({ statId, values });
}
if (options.expectedStatIds) {
const expected = [...options.expectedStatIds].sort();
const received = profiles.map((profile) => profile.statId).sort();
if (
expected.length !== received.length ||
expected.some((statId, index) => statId !== received[index])
)
return {
profiles: [],
error:
'The primary profile must contain the four fixed Skill Attributes.',
};
}
return { profiles, error: null };
}
+49
View File
@@ -1,10 +1,15 @@
import { describe, expect, it } from 'vitest';
import {
WEAPON_MOD_SLOT_DEFINITIONS,
WEAPON_MOD_SLOT_TYPES,
excludedTierZeroWeaponModSlot,
expandSharedWeaponModSlots,
normalizeWeaponModSlotPayload,
publicWeaponModSlots,
sharedWeaponModSlots,
validateWeaponModSlots,
weaponModSlotDefinitionById,
weaponModSlotDefinitionByName,
weaponModSlotsComplete,
type WeaponModSlotConfig,
} from './mod-slots';
@@ -15,6 +20,23 @@ const configuration = (tier: number, slots: string[]): WeaponModSlotConfig => ({
});
describe('weapon mod slot configuration', () => {
it('assigns one stable UUID to every canonical slot type', () => {
expect(WEAPON_MOD_SLOT_DEFINITIONS).toHaveLength(8);
expect(new Set(WEAPON_MOD_SLOT_DEFINITIONS.map(({ id }) => id)).size).toBe(
8,
);
expect(WEAPON_MOD_SLOT_DEFINITIONS.map(({ name }) => name)).toEqual(
WEAPON_MOD_SLOT_TYPES,
);
for (const definition of WEAPON_MOD_SLOT_DEFINITIONS) {
expect(definition.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
expect(weaponModSlotDefinitionById(definition.id)).toBe(definition);
expect(weaponModSlotDefinitionByName(definition.name)).toBe(definition);
}
});
it('accepts missing and partial slot data for later configuration', () => {
const slots = [
configuration(0, []),
@@ -99,4 +121,31 @@ describe('weapon mod slot configuration', () => {
]),
).toEqual([configuration(1, ['Optics', 'Muzzle', 'Barrel'])]);
});
it('marks the slot gained by a native T0 weapon at T1', () => {
const slots = expandSharedWeaponModSlots(
['Magazine', 'Optics', 'Barrel', 'Muzzle', 'Underbarrel'],
'Barrel',
);
expect(publicWeaponModSlots(0, slots)).toEqual([
{ slot: 'Optics', unlockedAtTierOne: false },
{ slot: 'Muzzle', unlockedAtTierOne: false },
{ slot: 'Underbarrel', unlockedAtTierOne: false },
{ slot: 'Magazine', unlockedAtTierOne: false },
{ slot: 'Barrel', unlockedAtTierOne: true },
]);
});
it('shows native T1 slots without an upgrade-only state', () => {
expect(
publicWeaponModSlots(1, [
configuration(1, ['Magazine', 'Optics', 'Muzzle']),
]),
).toEqual([
{ slot: 'Optics', unlockedAtTierOne: false },
{ slot: 'Muzzle', unlockedAtTierOne: false },
{ slot: 'Magazine', unlockedAtTierOne: false },
]);
});
});
+59 -9
View File
@@ -1,19 +1,42 @@
export const WEAPON_MOD_SLOT_TYPES = [
'Optics',
'Muzzle',
'Underbarrel',
'Magazine',
'Barrel',
'Upper Receiver',
'Lower Receiver',
'Trigger',
export const WEAPON_MOD_SLOT_DEFINITIONS = [
{ id: '28ffc3b1-5d98-4273-a9af-67dbff3e5c76', name: 'Optics' },
{ id: 'c4598f51-2cb0-4a20-a3a7-6d8983a26598', name: 'Muzzle' },
{ id: '85ca3493-1871-4d22-950a-ed718fd547e0', name: 'Underbarrel' },
{ id: '4d809a35-8285-4bff-be98-a0effe1d257e', name: 'Magazine' },
{ id: '0e157cfe-975c-441d-8f49-72983161e7b1', name: 'Barrel' },
{ id: '551fd7c8-3e5b-4c89-8437-9b901e89e8aa', name: 'Upper Receiver' },
{ id: '72573367-6e91-4312-9cc3-710ede4f078e', name: 'Lower Receiver' },
{ id: '8dda5edb-6079-4a23-a556-cd9be4521c70', name: 'Trigger' },
] as const;
export type WeaponModSlotType =
(typeof WEAPON_MOD_SLOT_DEFINITIONS)[number]['name'];
export const WEAPON_MOD_SLOT_TYPES = WEAPON_MOD_SLOT_DEFINITIONS.map(
(definition) => definition.name,
) as WeaponModSlotType[];
export function weaponModSlotDefinitionById(id: string) {
return WEAPON_MOD_SLOT_DEFINITIONS.find((definition) => definition.id === id);
}
export function weaponModSlotDefinitionByName(name: string) {
return WEAPON_MOD_SLOT_DEFINITIONS.find(
(definition) => definition.name === name,
);
}
export type WeaponModSlotConfig = {
tier: number;
slots: string[];
};
export type PublicWeaponModSlot = {
slot: string;
unlockedAtTierOne: boolean;
iconUrl?: string;
};
export function normalizeWeaponModSlotPayload(
payload: unknown,
): WeaponModSlotConfig[] {
@@ -52,6 +75,33 @@ export function sharedWeaponModSlots(
];
}
export function publicWeaponModSlots(
nativeTier: number,
configurations: WeaponModSlotConfig[],
): PublicWeaponModSlot[] {
const nativeSlots =
configurations.find((row) => Number(row.tier) === nativeTier)?.slots ?? [];
const displayedSlots =
nativeTier === 0
? sharedWeaponModSlots(configurations).length
? sharedWeaponModSlots(configurations)
: nativeSlots
: nativeSlots;
const ordered = [...new Set(displayedSlots)].sort(
(left, right) =>
WEAPON_MOD_SLOT_TYPES.indexOf(
left as (typeof WEAPON_MOD_SLOT_TYPES)[number],
) -
WEAPON_MOD_SLOT_TYPES.indexOf(
right as (typeof WEAPON_MOD_SLOT_TYPES)[number],
),
);
return ordered.map((slot) => ({
slot,
unlockedAtTierOne: nativeTier === 0 && !nativeSlots.includes(slot),
}));
}
export function excludedTierZeroWeaponModSlot(
sharedSlots: string[],
configurations: WeaponModSlotConfig[],
+55
View File
@@ -92,6 +92,10 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => {
WHERE users.id=?`)
).get(agentId);
const isSelf = viewer?.id === agentId;
const canManageNews =
isSelf &&
(viewerPermissions.includes('*') ||
viewerPermissions.includes('news.manage'));
if (unavailable(agent) || (!Boolean(agent?.is_public) && !isSelf))
error(404, 'Agent profile not found');
if (params.id !== String(agent.public_slug)) {
@@ -129,6 +133,9 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => {
profileCommentCount,
buildCount,
guideCount,
ownedBuildRows,
ownedGuideRows,
ownedNewsRows,
] = await Promise.all([
(
await database.prepare(`SELECT roles.name FROM user_roles
@@ -216,6 +223,28 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => {
await database.prepare(`SELECT COUNT(*) AS count FROM articles
WHERE kind='guide' AND owner_id=? AND visibility='public' AND deleted_at IS NULL`)
).get(agentId),
isSelf
? (
await database.prepare(`SELECT id,slug,title,visibility,updated_at
FROM builds WHERE owner_id=? ORDER BY updated_at DESC`)
).all(agentId)
: [],
isSelf
? (
await database.prepare(`SELECT articles.id,articles.slug,draft.title,articles.visibility,articles.updated_at
FROM articles JOIN article_drafts draft ON draft.article_id=articles.id
WHERE articles.kind='guide' AND articles.owner_id=? AND articles.deleted_at IS NULL
ORDER BY articles.updated_at DESC`)
).all(agentId)
: [],
canManageNews
? (
await database.prepare(`SELECT articles.id,articles.slug,draft.title,articles.visibility,articles.updated_at
FROM articles JOIN article_drafts draft ON draft.article_id=articles.id
WHERE articles.kind='news' AND articles.owner_id=? AND articles.deleted_at IS NULL
ORDER BY articles.updated_at DESC`)
).all(agentId)
: [],
]);
const loadedCommentIds = new Set(
@@ -401,6 +430,32 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => {
title: String(guide.title),
updatedAt: String(guide.updated_at),
})),
ownedContent: isSelf
? {
builds: ownedBuildRows.map((build) => ({
id: String(build.id),
slug: String(build.slug),
title: String(build.title),
visibility: String(build.visibility),
updatedAt: String(build.updated_at),
})),
guides: ownedGuideRows.map((guide) => ({
id: String(guide.id),
slug: String(guide.slug),
title: String(guide.title),
visibility: String(guide.visibility),
updatedAt: String(guide.updated_at),
})),
news: ownedNewsRows.map((news) => ({
id: String(news.id),
slug: String(news.slug),
title: String(news.title),
visibility: String(news.visibility),
updatedAt: String(news.updated_at),
})),
canManageNews,
}
: null,
authoredComments: authoredCommentRows.map((comment) => {
const targetType = String(comment.target_type);
const targetId = String(comment.target_id);
+49
View File
@@ -32,6 +32,7 @@
title: string;
updatedAt: string;
};
type OwnedContent = PublicContent & { visibility: string };
type AuthoredComment = {
id: string;
createdAt: string;
@@ -60,6 +61,12 @@
counts: { builds: number; guides: number; authoredComments: number; profileComments: number };
builds: PublicContent[];
guides: PublicContent[];
ownedContent: {
builds: OwnedContent[];
guides: OwnedContent[];
news: OwnedContent[];
canManageNews: boolean;
} | null;
authoredComments: AuthoredComment[];
profileComments: ProfileComment[];
viewer: { id: string | null; canComment: boolean; canReportProfile: boolean; isSelf: boolean };
@@ -86,6 +93,7 @@
? 'DATE UNKNOWN'
: date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }).toUpperCase();
};
const visibilityLabel = (visibility: string) => visibility.toUpperCase().replace('_', ' ');
$effect(() => {
const token = String(form?.profileCommentResultToken ?? form?.commentResultToken ?? '');
@@ -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);
+255 -11
View File
@@ -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,196 @@ function parseLoadout(raw: FormDataEntryValue | null) {
}
}
async function validateSkillChipSelections(
database: Awaited<ReturnType<typeof getDatabase>>,
loadout: LoadoutDraft,
) {
if (!loadout.chips.some((chip) => chip.setId)) return null;
const [
sets,
abilityLinks,
focusAbilities,
primaryOptions,
primaryValues,
secondaryOptions,
secondaryValues,
] = await Promise.all([
(
await database.prepare(
`SELECT chip.id,chip.specialization_ability_id
FROM skill_chip_sets chip
JOIN specialization_abilities ability
ON ability.id=chip.specialization_ability_id AND ability.active=1
WHERE chip.active=1`,
)
).all(),
(
await database.prepare(
`SELECT link.ability_id,link.compatible_ability_id
FROM specialization_ability_skill_chip_links link
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
JOIN specialization_abilities compatible ON compatible.id=link.compatible_ability_id AND compatible.active=1`,
)
).all(),
(
await database.prepare(
`SELECT link.ability_id,link.ability_slot
FROM specialization_focus_abilities link
JOIN specialization_focuses focus ON focus.id=link.focus_id AND focus.active=1
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
WHERE link.focus_id=? ORDER BY link.ability_slot`,
)
).all(loadout.specialization.focusId),
(
await database.prepare(
'SELECT stat_attribute_id FROM skill_chip_primary_options',
)
).all(),
(
await database.prepare('SELECT * FROM skill_chip_primary_quality_values')
).all(),
(
await database.prepare(
'SELECT stat_attribute_id FROM skill_chip_secondary_options',
)
).all(),
(
await database.prepare(
'SELECT * FROM skill_chip_secondary_quality_values',
)
).all(),
]);
const primaryIds = new Set(
primaryOptions.map((row) => String(row.stat_attribute_id)),
);
const secondaryIds = new Set(
secondaryOptions.map((row) => String(row.stat_attribute_id)),
);
const rangeFor = (rows: any[], statId: string, quality: string) =>
rows.find(
(row) =>
String(row.stat_attribute_id) === statId && row.quality === quality,
);
const inRange = (value: number, range: any) =>
range &&
value >= Number(range.min_value) &&
value <= Number(range.max_value);
for (let index = 0; index < loadout.chips.length; index += 1) {
const chip = loadout.chips[index];
if (!chip.setId) continue;
const definition = sets.find((row) => String(row.id) === chip.setId);
const expectedAbility = focusAbilities.find(
(row) => Number(row.ability_slot) === Math.floor(index / 3) + 1,
);
const ownerAbilityId = String(definition?.specialization_ability_id ?? '');
const expectedAbilityId = String(expectedAbility?.ability_id ?? '');
const sharedWithExpectedAbility = abilityLinks.some((link) => {
const left = String(link.ability_id);
const right = String(link.compatible_ability_id);
return (
(left === ownerAbilityId && right === expectedAbilityId) ||
(left === expectedAbilityId && right === ownerAbilityId)
);
});
if (
!definition ||
!expectedAbility ||
(ownerAbilityId !== expectedAbilityId && !sharedWithExpectedAbility)
)
return `Skill Chip ${index + 1} is not compatible with its assigned ability.`;
const primaryRange = rangeFor(
primaryValues,
chip.primary.statId,
chip.quality,
);
if (
!primaryIds.has(chip.primary.statId) ||
!inRange(chip.primary.value, primaryRange)
)
return `Skill Chip ${index + 1} has an invalid primary Attribute value.`;
if (
new Set(chip.secondary.map((roll) => roll.statId)).size !==
chip.secondary.length
)
return `Skill Chip ${index + 1} must use distinct secondary Attributes.`;
for (const roll of chip.secondary) {
const range = rangeFor(secondaryValues, roll.statId, chip.quality);
if (!secondaryIds.has(roll.statId) || !inRange(roll.value, range))
return `Skill Chip ${index + 1} has an invalid secondary Attribute value.`;
}
}
return null;
}
async function validateEquipmentTalentSelections(
database: Awaited<ReturnType<typeof getDatabase>>,
loadout: LoadoutDraft,
) {
const [gearTalents, weaponTalents, weapons, talentTiers] = await Promise.all([
(
await database.prepare('SELECT gear_id,talent_id FROM gear_talents')
).all(),
(
await database.prepare(
'SELECT weapon_id,talent_slot,talent_id FROM weapon_talent_options',
)
).all(),
(await database.prepare('SELECT id,exotic_talent_id FROM weapons')).all(),
(await database.prepare('SELECT talent_id,tier FROM talent_tiers')).all(),
]);
const hasTier = (talentId: string, tier: 1 | 2) =>
talentTiers.some(
(entry) =>
String(entry.talent_id) === talentId &&
String(entry.tier) === (tier === 2 ? 'II' : 'I'),
);
for (const slot of Object.keys(loadout.gear)) {
const selection = loadout.gear[slot];
if (!selection.talentId) continue;
if (
!gearTalents.some(
(entry) =>
String(entry.gear_id) === selection.itemId &&
String(entry.talent_id) === selection.talentId,
)
)
return `${slot} has a Talent that is not available for the equipped item.`;
if (!hasTier(selection.talentId, selection.talentTier))
return `${slot} Talent T${selection.talentTier} is not configured.`;
}
for (let index = 0; index < loadout.weapons.length; index += 1) {
const selection = loadout.weapons[index];
const label = index ? 'Secondary weapon' : 'Primary weapon';
for (const configured of [
{ id: selection.talent1Id, tier: selection.talent1Tier, slot: 1 },
{ id: selection.talent2Id, tier: selection.talent2Tier, slot: 2 },
]) {
if (!configured.id) continue;
if (
!weaponTalents.some(
(entry) =>
String(entry.weapon_id) === selection.itemId &&
Number(entry.talent_slot) === configured.slot &&
String(entry.talent_id) === configured.id,
)
)
return `${label} has a Talent that is not available in slot ${configured.slot}.`;
if (!hasTier(configured.id, configured.tier))
return `${label} Talent ${configured.slot} T${configured.tier} is not configured.`;
}
const weapon = weapons.find(
(entry) => String(entry.id) === selection.itemId,
);
const exoticTalentId = String(weapon?.exotic_talent_id ?? '');
if (exoticTalentId && !hasTier(exoticTalentId, selection.exoticTalentTier))
return `${label} exotic Talent T${selection.exoticTalentTier} is not configured.`;
}
return null;
}
export const load: PageServerLoad = async ({ cookies, url }) => {
const database = await getDatabase();
const viewer = await getSessionUser(cookies);
@@ -69,9 +260,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
talentEffects,
talentTemplates,
chipSets,
abilitySkillChipLinks,
chipTwoPiece,
chipPrimary,
chipSecondary,
chipPrimaryOptions,
chipPrimaryQualityValues,
chipSecondaryOptions,
chipSecondaryQualityValues,
chipTalents,
chipTalentEffects,
protocols,
@@ -159,12 +353,36 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
`SELECT link.ability_id,link.compatible_ability_id
FROM specialization_ability_skill_chip_links link
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
JOIN specialization_abilities compatible ON compatible.id=link.compatible_ability_id AND compatible.active=1`,
)
).all(),
(
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
).all(),
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
(
await database.prepare('SELECT * FROM skill_chip_secondary_options')
await database.prepare(
'SELECT stat_attribute_id FROM skill_chip_primary_options ORDER BY stat_attribute_id',
)
).all(),
(
await database.prepare(
'SELECT stat_attribute_id,quality,min_value,max_value FROM skill_chip_primary_quality_values ORDER BY stat_attribute_id,quality',
)
).all(),
(
await database.prepare(
'SELECT stat_attribute_id FROM skill_chip_secondary_options ORDER BY stat_attribute_id',
)
).all(),
(
await database.prepare(
'SELECT stat_attribute_id,quality,min_value,max_value FROM skill_chip_secondary_quality_values ORDER BY stat_attribute_id,quality',
)
).all(),
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
(
@@ -329,22 +547,40 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
weaponBonuses,
),
talents: configuredTalents,
skillChipProfile: {
primaryOptions: hydrateSkillChipProfileRows(
chipPrimaryOptions,
chipPrimaryQualityValues,
),
secondaryOptions: hydrateSkillChipProfileRows(
chipSecondaryOptions,
chipSecondaryQualityValues,
),
},
chipSets: chipSets.map((set) => {
const talent = chipTalents.find(
(item) => item.skill_chip_set_id === set.id,
);
return {
...set,
category:
specializationAbilities.find(
(ability) => ability.id === set.specialization_ability_id,
)?.name ?? 'SKILL CHIP SET',
compatibleAbilityIds: [
String(set.specialization_ability_id),
...abilitySkillChipLinks.flatMap((link) =>
link.ability_id === set.specialization_ability_id
? [String(link.compatible_ability_id)]
: link.compatible_ability_id === set.specialization_ability_id
? [String(link.ability_id)]
: [],
),
],
iconUrl: iconUrl('skill_chip', set.id),
twoPiece:
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
null,
primaryStatIds: chipPrimary
.filter((item) => item.skill_chip_set_id === set.id)
.map((item) => String(item.stat_attribute_id)),
secondaryStatIds: chipSecondary
.filter((item) => item.skill_chip_set_id === set.id)
.map((item) => String(item.stat_attribute_id)),
talent: talent
? {
...talent,
@@ -473,6 +709,14 @@ export const actions: Actions = {
return fail(400, { buildError: 'Choose a valid build visibility.' });
const database = await getDatabase();
const equipmentTalentError = await validateEquipmentTalentSelections(
database,
loadout,
);
if (equipmentTalentError)
return fail(400, { buildError: equipmentTalentError });
const skillChipError = await validateSkillChipSelections(database, loadout);
if (skillChipError) return fail(400, { buildError: skillChipError });
const now = new Date().toISOString();
let buildId = id;
let slug = '';
+262 -123
View File
@@ -11,12 +11,15 @@
import BuildShareControl from '$lib/components/BuildShareControl.svelte';
import LoadoutMatrix, { type MatrixCell } from '$lib/components/builder/LoadoutMatrix.svelte';
import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte';
import WeaponBaselineThroughput from '$lib/components/builder/WeaponBaselineThroughput.svelte';
import PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte';
import TalentTierChoice from '$lib/components/builder/TalentTierChoice.svelte';
import { summarizeLoadout } from '$lib/builder/loadout-summary';
import { calculateWeaponBaseline } from '$lib/builder/weapon-baseline';
import { toast } from '$lib/toast';
import {
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, weaponBaseRange,
type LoadoutDraft, type Roll
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, migrateLoadoutDraftV5, parseLoadoutDraft, remapRangeValue, scaled, skillChipQualities, skillChipSecondaryCapacity, weaponBaseRange,
type LoadoutDraft, type Roll, type SkillChipQuality, type TalentTier
} from '$lib/builder/loadout';
let { data, form } = $props();
@@ -35,11 +38,29 @@
const coreKeys = ['firepower','toughness','engineering'] as const;
const gearAttributes = $derived(data.attributes.filter((item) => item.gear_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
const weaponAttributes = $derived(data.attributes.filter((item) => item.weapon_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
const chipAttributes = $derived(data.attributes.filter((item) => item.skill_chip_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
const attribute = (id: string) => data.attributes.find((item) => item.id === id);
const chipPrimaryAttributes = $derived(data.skillChipProfile.primaryOptions.map((option: any) => attribute(option.statId)).filter(Boolean).map((item: any) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
const chipSecondaryAttributes = $derived(data.skillChipProfile.secondaryOptions.map((option: any) => attribute(option.statId)).filter(Boolean).map((item: any) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
const chipRange = (kind: 'primary' | 'secondary', statId: string, quality: SkillChipQuality) =>
data.skillChipProfile[`${kind}Options`].find((option: any) => option.statId === statId)?.ranges?.[quality] ?? null;
type ChipRange = { min: number; max: number };
const validChipRange = (range: any): range is ChipRange => Number.isFinite(Number(range?.min)) && Number.isFinite(Number(range?.max)) && Number(range.max) >= Number(range.min);
const chipRangeMin = (kind: 'primary' | 'secondary', statId: string, quality: SkillChipQuality) => Number(chipRange(kind, statId, quality)?.min ?? 0);
const chipRangeMax = (kind: 'primary' | 'secondary', statId: string, quality: SkillChipQuality) => Number(chipRange(kind, statId, quality)?.max ?? 0);
const chipProfileConfigured = $derived(
data.skillChipProfile.primaryOptions.length === 4
&& chipPrimaryAttributes.length === data.skillChipProfile.primaryOptions.length
&& data.skillChipProfile.primaryOptions.every((option: any) => skillChipQualities.every((quality) => validChipRange(option.ranges?.[quality])))
&& data.skillChipProfile.secondaryOptions.length >= 2
&& chipSecondaryAttributes.length === data.skillChipProfile.secondaryOptions.length
&& data.skillChipProfile.secondaryOptions.every((option: any) => ['Specialized','Superior','High-end'].every((quality) => validChipRange(option.ranges?.[quality])))
);
const gearItem = (slot: string) => data.gear.find((item) => item.id === draft.gear[slot].itemId);
const weaponItem = (index: number) => data.weapons.find((item) => item.id === draft.weapons[index].itemId);
const chipItem = (index: number) => data.chipSets.find((item) => item.id === draft.chips[index].setId);
const chipSupportsAbility = (item: any, abilityId: string) =>
item?.compatibleAbilityIds?.includes(abilityId)
?? item?.specialization_ability_id === abilityId;
const protocolItem = () => data.protocols.find((item) => item.id === draft.protocol.itemId);
const allFocuses = $derived(data.specializations.flatMap((specialization: any) =>
specialization.focuses.map((focus: any) => ({ ...focus, specialization }))
@@ -56,6 +77,8 @@
return selectedRank ? [{ node, rank: node.ranks.find((entry: any) => Number(entry.rank) === selectedRank) }] : [];
}) ?? []);
const talent = (id: string) => data.talents.find((item) => item.id === id);
const supportedTalentTier = (id: string, tier: TalentTier): TalentTier =>
talent(id)?.tiers.some((entry: any) => entry.tier === (tier === 2 ? 'II' : 'I')) ? tier : 1;
const brand = (id: string) => data.brands.find((item) => item.id === id);
const unitValue = (value: unknown, statId: string) => {
const numeric = Number(value);
@@ -80,13 +103,36 @@
coreKeys.reduce((total, key) => total + Number(selection[key] ?? 0), 0);
const attributeRange = (stat: any, source: 'gear' | 'weapon', level: number) =>
scaledRange(stat?.[`${source}_min`], stat?.[`${source}_max`], level, stat?.[`${source}_reference_level`], source);
const setRollStat = (roll: Roll, id: string, source: 'gear' | 'weapon' | 'chip', level = 50) => {
const setRollStat = (roll: Roll, id: string, source: 'gear' | 'weapon', level = 50) => {
roll.statId = id;
const stat = attribute(id);
const min = source === 'chip'
? stat?.skill_chip_min
: attributeRange(stat, source, level).min;
roll.value = Number(min ?? 0);
roll.value = Number(attributeRange(stat, source, level).min ?? 0);
};
const setChipRoll = (roll: Roll, id: string, kind: 'primary' | 'secondary', quality: SkillChipQuality) => {
roll.statId = id;
const range = chipRange(kind, id, quality);
roll.value = validChipRange(range) ? Number(range.min) : 0;
};
const chipRollConfigured = (roll: Roll, kind: 'primary' | 'secondary', quality: SkillChipQuality) => {
const options = data.skillChipProfile[`${kind}Options`];
const range = chipRange(kind, roll.statId, quality);
const value = Number(roll.value);
return options.some((option: any) => option.statId === roll.statId)
&& validChipRange(range)
&& Number.isFinite(value)
&& value >= Number(range.min)
&& value <= Number(range.max);
};
const chipSelectionConfigured = (chip: LoadoutDraft['chips'][number], abilityId: string) => {
const capacity = skillChipSecondaryCapacity[chip.quality];
const secondaryIds = chip.secondary.map((roll) => roll.statId);
const definition = data.chipSets.find((item: any) => item.id === chip.setId);
return Boolean(definition && chipSupportsAbility(definition, abilityId))
&& chipProfileConfigured
&& chipRollConfigured(chip.primary, 'primary', chip.quality)
&& chip.secondary.length === capacity
&& new Set(secondaryIds).size === secondaryIds.length
&& chip.secondary.every((roll) => chipRollConfigured(roll, 'secondary', chip.quality));
};
const rankPointCost = (rank: number) => Number(data.specializationProgression.rankProfiles.find((profile: any) => Number(profile.rank) === rank)?.point_cost ?? rank);
const selectedSpecializationPoints = $derived(selectedFocus?.nodes.reduce((total: number, node: any) => {
@@ -131,14 +177,14 @@
const chip = draft.chips[index];
const ability = next.abilities[Math.floor(index / 3)];
const definition = data.chipSets.find((item: any) => item.id === chip.setId);
if (chip.setId && (!ability || definition?.specialization_ability_id !== ability.id)) {
if (chip.setId && (!ability || !chipSupportsAbility(definition, ability.id))) {
displaced.push(structuredClone(chip));
draft.chips[index] = createDraft().chips[index];
}
}
for (const chip of displaced) {
const definition = data.chipSets.find((item: any) => item.id === chip.setId);
const abilityIndex = next.abilities.findIndex((ability: any) => ability.id === definition?.specialization_ability_id);
const abilityIndex = next.abilities.findIndex((ability: any) => chipSupportsAbility(definition, ability.id));
if (abilityIndex < 0) continue;
const targetIndex = [0, 1, 2].map((slot) => abilityIndex * 3 + slot).find((slot) => !draft.chips[slot].setId);
if (targetIndex === undefined) continue;
@@ -170,6 +216,7 @@
selection.level = 50;
selection.brandId = item?.brandIds[0] ?? '';
selection.talentId = item?.talentIds[0] ?? '';
selection.talentTier = supportedTalentTier(selection.talentId, Number(item?.tier) >= 2 ? 2 : 1);
selection.health = Number(item?.base_health_min ?? 0);
selection.armor = Number(item?.base_armor_min ?? 0);
const core = coreAllocationRange(Number(item?.tier ?? 0), selection.level, 'gear');
@@ -218,19 +265,48 @@
const core = coreAllocationRange(selection.tier, selection.level, 'weapon');
for (const key of coreKeys) selection[key] = core.min;
selection.talent1Id = item?.talent1Ids[0] ?? '';
selection.talent1Tier = supportedTalentTier(selection.talent1Id, selection.tier >= 2 ? 2 : 1);
selection.talent2Id = item?.talent2Ids[0] ?? '';
selection.talent2Tier = supportedTalentTier(selection.talent2Id, selection.tier >= 2 ? 2 : 1);
selection.exoticTalentTier = supportedTalentTier(item?.exotic_talent_id ?? '', selection.tier >= 2 ? 2 : 1);
selection.rolls = [blankRoll(), blankRoll()];
};
const configureChip = (index: number, id: string) => {
const chip = draft.chips[index], item = data.chipSets.find((entry) => entry.id === id);
const chip = draft.chips[index];
chip.setId = id;
chip.primary = { statId: item?.primaryStatIds[0] ?? '', value: 0 };
chip.secondary = [
{ statId: item?.secondaryStatIds[0] ?? '', value: 0 },
{ statId: item?.secondaryStatIds[1] ?? '', value: 0 }
];
if (chip.primary.statId) setRollStat(chip.primary, chip.primary.statId, 'chip');
for (const roll of chip.secondary) if (roll.statId) setRollStat(roll, roll.statId, 'chip');
chip.primary = blankRoll();
chip.secondary = [];
changeChipQuality(index, chip.quality);
};
const changeChipQuality = (index: number, quality: SkillChipQuality) => {
if (!skillChipQualities.includes(quality)) return;
const chip = draft.chips[index];
chip.quality = quality;
const primaryOption = data.skillChipProfile.primaryOptions.find((option: any) => option.statId === chip.primary.statId && validChipRange(option.ranges?.[quality]))
?? data.skillChipProfile.primaryOptions.find((option: any) => validChipRange(option.ranges?.[quality]));
if (primaryOption) {
const range = primaryOption.ranges[quality];
chip.primary = {
statId: primaryOption.statId,
value: clamp(chip.primary.statId === primaryOption.statId ? chip.primary.value : range.min, range.min, range.max)
};
} else chip.primary = blankRoll();
const capacity = skillChipSecondaryCapacity[quality];
const next: Roll[] = [];
for (const roll of chip.secondary) {
if (next.length >= capacity || next.some((entry) => entry.statId === roll.statId)) continue;
const range = chipRange('secondary', roll.statId, quality);
if (!validChipRange(range)) continue;
next.push({ statId: roll.statId, value: clamp(roll.value, range.min, range.max) });
}
for (const option of data.skillChipProfile.secondaryOptions) {
if (next.length >= capacity) break;
if (next.some((entry) => entry.statId === option.statId)) continue;
const range = option.ranges?.[quality];
if (validChipRange(range)) next.push({ statId: option.statId, value: Number(range.min) });
}
while (next.length < capacity) next.push(blankRoll());
chip.secondary = next;
};
const changeWeaponTier = (index: number, tier: number) => {
const selection = draft.weapons[index], item = weaponItem(index);
@@ -266,6 +342,27 @@
};
const activeParts = $derived(active.split(':'));
const summary = $derived(summarizeLoadout(draft, data));
const weaponBaselines = $derived(draft.weapons.map((selection, index) => {
const item = data.weapons.find((entry) => entry.id === selection.itemId);
return {
slot: index ? 'SECONDARY' as const : 'PRIMARY' as const,
id: String(item?.id ?? ''),
name: item?.name ?? 'UNASSIGNED',
damage: Number.isFinite(Number(selection.damage)) ? Number(selection.damage) : null,
rpm: item?.rpm == null ? null : Number(item.rpm),
magazine: item?.magazine == null ? null : Number(item.magazine),
reloadSpeed: item?.reload_speed == null ? null : Number(item.reload_speed),
baseline: calculateWeaponBaseline({
weaponId: String(item?.id ?? 'NO CATALOG ID'),
weaponName: item?.name,
weaponLabel: index ? 'SECONDARY' : 'PRIMARY',
selectedDamage: selection.damage,
rpm: item?.rpm,
magazine: item?.magazine,
reloadSeconds: item?.reload_speed
})
};
}));
const rollSummary = (rolls: Roll[]) => rolls
.filter((roll) => roll.statId)
.map((roll) => `${attribute(roll.statId)?.name ?? 'Attribute'} ${unitValue(roll.value, roll.statId)}`)
@@ -282,45 +379,49 @@
const value = String(category ?? '').toLowerCase();
if (value.includes('shotgun')) return '/shotgun.png';
if (value.includes('smg') || value.includes('submachine')) return '/smg.png';
if (value.includes('mmr') || value.includes('marksman')) return '/mmr.png';
if (value.includes('lmg') || value.includes('light machine')) return '/lmg.png';
if (value.includes('ar') || value.includes('assault')) return '/ar.png';
return '/rifle.png';
};
const doctrineCell = $derived<MatrixCell>({
id: 'specialization:focus',
label: 'SPECIALIZATION + FOCUS',
name: selectedFocus ? `${selectedFocus.specialization.name} · ${selectedFocus.name}` : 'SELECT DOCTRINE',
label: 'FOCUS',
name: selectedFocus ? `${selectedFocus.specialization.name} · ${selectedFocus.name}` : 'SELECT FOCUS',
meta: selectedFocus?.primary_attribute ?? '',
summary: selectedFocus
? `${selectedFocus.primary_attribute} · ${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS`
: 'ROOT CHOICE · DEFINES ALL THREE ABILITIES',
: 'SETS 3 ABILITIES',
iconUrl: selectedFocus?.specialization.iconUrl,
issue: !selectedFocus ? 'SELECT TO COMPLETE LOADOUT' : selectedFocus.abilities.length !== 3 ? 'ABILITY DATA INCOMPLETE' : undefined
issue: !selectedFocus ? 'Select a focus.' : selectedFocus.abilities.length !== 3 ? 'Focus needs 3 abilities.' : undefined
});
const abilityCells = $derived.by((): MatrixCell[] => [0, 1, 2].map((index) => {
const ability = selectedFocus?.abilities[index];
const configuredChips = draft.chips.slice(index * 3, index * 3 + 3).filter((chip) => chip.setId);
const abilityChips = draft.chips.slice(index * 3, index * 3 + 3);
const configuredChips = abilityChips.filter((chip) => chip.setId);
const chipNames = configuredChips.map((chip) => data.chipSets.find((item) => item.id === chip.setId)?.name).filter(Boolean);
return {
id: `ability:${index}`,
label: `ABILITY 0${index + 1}`,
name: ability?.name ?? 'NOT ASSIGNED',
label: `ABILITY ${index + 1}`,
name: ability?.name ?? 'UNASSIGNED',
meta: ability?.attack?.damage_type ?? '',
summary: chipNames.length ? chipNames.join(' · ') : `${configuredChips.length} / 3 SKILL CHIPS`,
iconUrl: ability?.iconUrl,
issue: !ability ? 'ABILITY NOT CONFIGURED' : configuredChips.length < 3 ? `${3 - configuredChips.length} CHIP${configuredChips.length === 2 ? '' : 'S'} REQUIRED` : undefined
issue: !ability ? 'Ability required.' : configuredChips.length < 3 ? `${3 - configuredChips.length} chip${configuredChips.length === 2 ? '' : 's'} required.` : abilityChips.some((chip) => !chipSelectionConfigured(chip, ability.id)) ? 'Chip setup required.' : undefined
};
}));
const weaponCells = $derived.by((): MatrixCell[] => draft.weapons.map((selection, index) => {
const item = weaponItem(index);
const talents = [selection.talent1Id, selection.talent2Id].map((id) => talent(id)?.name).filter(Boolean);
const talents = [[selection.talent1Id, selection.talent1Tier], [selection.talent2Id, selection.talent2Tier]].map(([id,tier]) => id ? `${talent(String(id))?.name ?? 'Talent'} T${tier}` : '').filter(Boolean);
const details = [...talents, rollSummary(selection.rolls)].filter(Boolean).join(' · ');
return {
id: `weapon:${index}`,
label: `${index ? 'SECONDARY' : 'PRIMARY'} WEAPON`,
name: item?.name ?? 'EMPTY SLOT',
label: index ? 'SECONDARY' : 'PRIMARY',
name: item?.name ?? 'EMPTY',
meta: item?.category ?? '',
summary: item ? details || `T${selection.tier} · ${unitValue(selection.damage, '')} DAMAGE` : 'SELECT WEAPON',
summary: item ? details || `T${selection.tier} · ${unitValue(selection.damage, '')} DAMAGE` : 'CHOOSE WEAPON',
iconUrl: weaponIcon(item?.category),
issue: item ? undefined : 'SELECT TO COMPLETE LOADOUT'
issue: item ? undefined : 'Choose a weapon.'
};
}));
const gearCells = $derived.by((): MatrixCell[] => gearSlots.map((slot) => {
@@ -328,53 +429,64 @@
const item = gearItem(slot);
const details = [
brand(selection.brandId)?.name,
talent(selection.talentId)?.name,
selection.talentId ? `${talent(selection.talentId)?.name ?? 'Talent'} T${selection.talentTier}` : '',
rollSummary(selection.rolls)
].filter(Boolean).join(' · ');
return {
id: `gear:${slot}`,
label: slot.toUpperCase(),
name: item?.name ?? 'EMPTY SLOT',
name: item?.name ?? 'EMPTY',
meta: item ? `T${item.tier}` : '',
summary: item ? details || `T${item.tier} · LEVEL ${selection.level}` : 'SELECT GEAR',
summary: item ? details || `T${item.tier} · LEVEL ${selection.level}` : 'CHOOSE GEAR',
iconUrl: gearIcon(slot),
issue: item ? undefined : 'SELECT TO COMPLETE LOADOUT'
issue: item ? undefined : 'Choose gear.'
};
}));
const protocolCell = $derived<MatrixCell>({
id: 'protocol:0',
label: 'OS PROTOCOL',
name: protocolItem()?.name ?? 'EMPTY SLOT',
label: 'PROTOCOL',
name: protocolItem()?.name ?? 'EMPTY',
meta: protocolItem()?.quality ?? '',
summary: protocolItem()
? `LEVEL ${draft.protocol.level} · ${draft.protocol.talentUpgrades} / 5 TALENT UPGRADES`
: 'SELECT ONE PROTOCOL',
: 'CHOOSE PROTOCOL',
iconUrl: protocolItem()?.iconUrl,
issue: protocolItem() ? undefined : 'SELECT TO COMPLETE LOADOUT'
issue: protocolItem() ? undefined : 'Choose a protocol.'
});
const loadoutConfigured = $derived(Boolean(
selectedFocus
&& selectedFocus.abilities.length === 3
&& draft.weapons.every((item) => item.itemId)
&& gearSlots.every((slot) => draft.gear[slot].itemId)
&& draft.chips.every((item) => item.setId)
&& draft.chips.every((chip, index) => chipSelectionConfigured(
chip,
selectedFocus.abilities[Math.floor(index / 3)]?.id ?? ''
))
&& draft.protocol.itemId
));
onMount(() => {
if (data.initialBuild) return;
localStorage.removeItem('shd-build-loadout-draft-v3');
const stored = localStorage.getItem('shd-build-loadout-draft-v4');
localStorage.removeItem('shd-build-loadout-draft-v4');
if (data.initialBuild) return;
const current = localStorage.getItem('shd-build-loadout-draft-v6');
const legacy = current ? null : localStorage.getItem('shd-build-loadout-draft-v5');
const stored = current ?? legacy;
if (!stored) return;
try {
const parsed = parseLoadoutDraft(JSON.parse(stored));
const raw = JSON.parse(stored);
const parsed = current ? parseLoadoutDraft(raw) : migrateLoadoutDraftV5(raw, data);
if (parsed) {
draft = parsed;
if (legacy) {
localStorage.setItem('shd-build-loadout-draft-v6', JSON.stringify(parsed));
localStorage.removeItem('shd-build-loadout-draft-v5');
}
if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = '';
saved = true;
} else localStorage.removeItem('shd-build-loadout-draft-v4');
} else localStorage.removeItem(current ? 'shd-build-loadout-draft-v6' : 'shd-build-loadout-draft-v5');
} catch {
localStorage.removeItem('shd-build-loadout-draft-v4');
localStorage.removeItem(current ? 'shd-build-loadout-draft-v6' : 'shd-build-loadout-draft-v5');
}
});
$effect(() => {
@@ -392,7 +504,7 @@
: 'Build published to the SHD archive.';
});
function saveDraft() {
localStorage.setItem('shd-build-loadout-draft-v4', JSON.stringify(draft));
localStorage.setItem('shd-build-loadout-draft-v6', JSON.stringify(draft));
saved = true;
toast.success('Draft saved locally', 'This loadout is available on this device.');
}
@@ -402,10 +514,10 @@
<main class="app-page builder-page">
<div class="builder-command">
<div class="page-title">
<div><p class="eyebrow"><span></span> COMMAND GRID / LOADOUT 01</p><input id="builder-build-name" name="builder-build-name" bind:value={draft.buildName} aria-label="Build name" maxlength="100"/></div>
<div><p class="eyebrow"><span></span> BUILDER / LOADOUT 01</p><input id="builder-build-name" name="builder-build-name" bind:value={draft.buildName} aria-label="Build name" maxlength="100"/></div>
<div class="actions">
<span class:visible={saved}>SAVED LOCALLY</span>
<button class="secondary" onclick={saveDraft}>SAVE LOCALLY</button>
<span class:visible={saved}>LOCAL SAVED</span>
<button class="secondary" onclick={saveDraft}>SAVE</button>
{#if data.viewer}
<PendingForm action="?/save" class="builder-save-form">
{#snippet children(pending)}
@@ -421,21 +533,21 @@
</div>
{#if data.viewer}
<div class="builder-persistence">
<SegmentedChoice legend="ACCOUNT VISIBILITY" name="build-visibility" options={[
<SegmentedChoice legend="VISIBILITY" name="build-visibility" options={[
{ value:'private', label:'PRIVATE' },
{ value:'unlisted', label:'UNLISTED' },
{ value:'public', label:'PUBLIC' }
]} value={persistedVisibility} onchange={(values) => persistedVisibility = values[0] as BuildVisibility} required/>
<p>{persistedVisibility === 'private' ? 'Only you can open this build.' : persistedVisibility === 'unlisted' ? 'Anyone with its direct link can open this build.' : 'Listed publicly and accessible by direct link.'}</p>
<p>{persistedVisibility === 'private' ? 'Private to you.' : persistedVisibility === 'unlisted' ? 'Direct link only.' : 'Public listing and link.'}</p>
{#if persistedSlug && savedBuildVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
</div>
{/if}
<div class="builder-status"><span><i></i> KNOWN STATS ONLINE</span><span>ADVANCED MODEL · COMING SOON</span><span>12 LOADOUT CELLS + 9 CHIPS</span><span>{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS</span></div>
<div class="builder-status"><span><i></i> STATS ONLINE</span></div>
</div>
{#if message}<p class="success" role="status">{message}</p>{/if}
{#if form?.buildError}<p class="error" role="alert">{form.buildError}</p>{/if}
{#if data.viewer && persistedSlug}<BuildShareControl title={savedBuildTitle} slugOrUrl={new URL(`/builds/${encodeURIComponent(persistedSlug)}`, data.publicOrigin).toString()} visibility={savedBuildVisibility}/>{/if}
{#if !data.gear.length && !data.weapons.length && !data.chipSets.length && !data.protocols.length}<div class="builder-empty-data"><strong>ISAC DATA LINK EMPTY</strong><span>Add active equipment definitions in ISAC to populate this workbench.</span></div>{/if}
{#if !data.gear.length && !data.weapons.length && !data.chipSets.length && !data.protocols.length}<div class="builder-empty-data"><strong>NO BUILDER DATA</strong><span>Add active items in ISAC.</span></div>{/if}
<div class="loadout-workspace">
<LoadoutMatrix
{active}
@@ -450,115 +562,142 @@
<section class="loadout-editor">
{#if activeParts[0] === 'specialization'}
<div class="loadout-editor-head"><div><span>AGENT CLASS</span><h2>SPECIALIZATION FOCUS</h2></div><b>{selectedFocus ? `${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS` : 'UNASSIGNED'}</b></div>
<BuilderPicker items={specializationItems} value={draft.specialization.focusId} onchange={selectFocus} label="SPECIALIZATION + FOCUS"/>
<div class="loadout-editor-head"><div><span>SPECIALIZATION</span><h2>FOCUS</h2></div><b>{selectedFocus ? `${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS` : 'UNASSIGNED'}</b></div>
<BuilderPicker items={specializationItems} value={draft.specialization.focusId} onchange={selectFocus} label="FOCUS"/>
{#if selectedFocus}
<div class="builder-specialization-identity"><span class="focus-composite-icon"><ItemIcon src={selectedFocus.specialization.iconUrl} alt={selectedFocus.specialization.name}/><GameIcon name={selectedFocus.primary_attribute.toLocaleLowerCase() as GameIconName} decorative/></span><div><small>{selectedFocus.specialization.name}</small><h3>{selectedFocus.name}</h3><p>{selectedFocus.description || 'No focus description has been entered.'}</p></div><b class:configured={Number(selectedFocus.builder_ready) === 1}>{Number(selectedFocus.builder_ready) === 1 ? 'BUILDER READY' : 'ISAC DATA INCOMPLETE'}</b></div>
<div class="builder-ability-chip-panels">
{#each [0,1,2] as abilityIndex}
{@const ability = selectedFocus.abilities[abilityIndex]}
<section>
<header><ItemIcon src={ability?.iconUrl} alt={ability?.name ?? `Ability ${abilityIndex + 1}`}/><div><small>ABILITY {abilityIndex + 1}</small><strong>{ability?.name ?? 'NOT CONFIGURED'}</strong></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3 CHIPS</b></header>
<p>{ability?.description ?? 'ISAC requires this class ability before compatible Skill Chips can be equipped.'}</p>
<div>{#each [0,1,2] as chipSlot}{@const chipIndex = abilityIndex * 3 + chipSlot}{@const item = chipItem(chipIndex)}<button type="button" onclick={() => active = `chip:${chipIndex}`}><small>CHIP {chipSlot + 1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? draft.chips[chipIndex].quality : 'SELECT CHIP'}</span></button>{/each}</div>
</section>
{/each}
</div>
<div class="builder-specialization-telemetry"><span>POINTS <b>{selectedSpecializationPoints} / {data.specializationProgression.availablePoints}</b></span><span>CREDITS <b>{selectedSpecializationCosts.credits.toLocaleString()}</b></span>{#each selectedSpecializationCosts.resources as resource}<span>{String(resource[0]).replace('resource-electronics-','').replaceAll('-',' ').toUpperCase()} <b>× {resource[1]}</b></span>{/each}</div>
<div class="builder-focus-tree">
<div class="builder-section-title"><div><span>FOCUS UPGRADE TREE</span><small>PREVIOUS ROWS REQUIRE EVERY AVAILABLE RANK 1 + 2 BEFORE ADVANCING</small></div><b>{selectedFocus.nodes.length} NODES</b></div>
{#each [...new Set(selectedFocus.nodes.map((node: any) => Number(node.row_number)))] as row}
<section><header><span>ROW {String(row).padStart(2, '0')}</span><b>{row === 1 ? 'ENTRY ROW' : 'LOCKED BY PREVIOUS ROWS'}</b></header><div>
{#each selectedFocus.nodes.filter((node: any) => Number(node.row_number) === row) as node}
<article><ItemIcon src={node.iconUrl} alt={node.name}/><div><strong>{node.name}</strong><small>{node.description_template}</small></div><SegmentedChoice legend={`${node.name} rank`} name={`specialization-node-${node.id}`} options={[{value:'0',label:'OFF'},...Array.from({length:Number(node.max_rank)},(_,index)=>({value:String(index+1),label:`R${index+1}`,className:canSelectNodeRank(node,index+1)?'':'unavailable',disabled:!canSelectNodeRank(node,index+1)}))]} value={String(draft.specialization.nodeRanks[node.id] ?? 0)} onchange={(values) => setNodeRank(node,Number(values[0]))}/></article>
<div class="builder-specialization-layout">
<div class="builder-editor-lane">
<div class="builder-specialization-identity"><span class="focus-composite-icon"><ItemIcon src={selectedFocus.specialization.iconUrl} alt=""/><GameIcon name={selectedFocus.primary_attribute.toLocaleLowerCase() as GameIconName} decorative/></span><div><small>{selectedFocus.specialization.name}</small><h3>{selectedFocus.name}</h3><p>{selectedFocus.description || 'No focus description.'}</p></div><b class:configured={Number(selectedFocus.builder_ready) === 1}>{Number(selectedFocus.builder_ready) === 1 ? 'READY' : 'NEEDS DATA'}</b></div>
<div class="builder-focus-abilities" aria-label="Assigned abilities">
{#each [0,1,2] as abilityIndex}
{@const ability = selectedFocus.abilities[abilityIndex]}
<button type="button" onclick={() => active = `ability:${abilityIndex}`}><ItemIcon src={ability?.iconUrl} alt="" className="builder-ability-icon"/><span><small>ABILITY {abilityIndex + 1}</small><strong>{ability?.name ?? 'UNASSIGNED'}</strong></span><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3</b></button>
{/each}
</div></section>
{/each}
</div>
<div class="builder-specialization-telemetry"><span>POINTS <b>{selectedSpecializationPoints} / {data.specializationProgression.availablePoints}</b></span><span>CREDITS <b>{selectedSpecializationCosts.credits.toLocaleString()}</b></span>{#each selectedSpecializationCosts.resources as resource}<span>{String(resource[0]).replace('resource-electronics-','').replaceAll('-',' ').toUpperCase()} <b>× {resource[1]}</b></span>{/each}</div>
</div>
<div class="builder-focus-tree builder-editor-lane">
<div class="builder-section-title"><div><span>UPGRADE TREE</span><small>UNLOCK IN ORDER</small></div><b>{selectedFocus.nodes.length} NODES</b></div>
{#each [...new Set(selectedFocus.nodes.map((node: any) => Number(node.row_number)))] as row}
<section><header><span>ROW {String(row).padStart(2, '0')}</span><b>{row === 1 ? 'START' : 'PRIOR ROWS'}</b></header><div>
{#each selectedFocus.nodes.filter((node: any) => Number(node.row_number) === row) as node}
<article><ItemIcon src={node.iconUrl} alt={node.name}/><div><strong>{node.name}</strong><small>{node.description_template}</small></div><SegmentedChoice legend={`${node.name} rank`} name={`specialization-node-${node.id}`} options={[{value:'0',label:'OFF'},...Array.from({length:Number(node.max_rank)},(_,index)=>({value:String(index+1),label:`R${index+1}`,className:canSelectNodeRank(node,index+1)?'':'unavailable',disabled:!canSelectNodeRank(node,index+1)}))]} value={String(draft.specialization.nodeRanks[node.id] ?? 0)} onchange={(values) => setNodeRank(node,Number(values[0]))}/></article>
{/each}
</div></section>
{/each}
</div>
</div>
<PlannedCombatModel headingId="builder-combat-model-heading" />
{/if}
{:else if activeParts[0] === 'ability'}
{@const abilityIndex = Number(activeParts[1])}
{@const ability = selectedFocus?.abilities[abilityIndex]}
<div class="loadout-editor-head"><div><span>DOCTRINE DEPENDENCY · ABILITY 0{abilityIndex + 1}</span><h2>{ability?.name?.toUpperCase() ?? 'ABILITY NOT ASSIGNED'}</h2></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3 CHIPS</b></div>
<button class="builder-return-link" type="button" onclick={() => active = 'specialization:focus'}>← CHANGE SPECIALIZATION + FOCUS</button>
<div class="loadout-editor-head"><div><span>ABILITY {abilityIndex + 1}</span><h2>{ability?.name?.toUpperCase() ?? 'UNASSIGNED'}</h2></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3 CHIPS</b></div>
<button class="builder-return-link" type="button" onclick={() => active = 'specialization:focus'}>← BACK TO FOCUS</button>
{#if ability}
<div class="builder-specialization-identity ability-identity"><ItemIcon src={ability.iconUrl} alt={ability.name}/><div><small>{selectedFocus.specialization.name} · {selectedFocus.name}</small><h3>{ability.name}</h3><p>{ability.description || 'No ability description has been entered.'}</p></div><b>{ability.attack ? `${ability.attack.delivery.toUpperCase()} · ${ability.attack.damage_type}` : 'ATTACK DATA INCOMPLETE'}</b></div>
<div class="builder-section-title"><div><span>ATTACHED SKILL CHIPS</span><small>CHIPS ARE COMPATIBLE WITH THIS ABILITY AND MOVE WITH ITS FOCUS</small></div><b>03 POSITIONS</b></div>
<div class="builder-specialization-identity ability-identity"><ItemIcon src={ability.iconUrl} alt="" className="ability-icon"/><div><small>{selectedFocus.specialization.name} · {selectedFocus.name}</small><h3>{ability.name}</h3><p>{ability.description || 'No ability description.'}</p></div><b>{ability.attack ? `${ability.attack.delivery.toUpperCase()} · ${ability.attack.damage_type}` : 'ATTACK DATA NEEDED'}</b></div>
<div class="builder-section-title"><div><span>SKILL CHIPS</span><small>3 SETS</small></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3</b></div>
<div class="builder-ability-chip-panels single">
<section>
<div>{#each [0,1,2] as chipSlot}{@const chipIndex = abilityIndex * 3 + chipSlot}{@const item = chipItem(chipIndex)}<button type="button" onclick={() => active = `chip:${chipIndex}`}><small>CHIP {chipSlot + 1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${draft.chips[chipIndex].quality} · ${attribute(draft.chips[chipIndex].primary.statId)?.name ?? 'NO PRIMARY'}` : 'SELECT CHIP'}</span></button>{/each}</div>
<div>{#each [0,1,2] as chipSlot}{@const chipIndex = abilityIndex * 3 + chipSlot}{@const item = chipItem(chipIndex)}<button type="button" onclick={() => active = `chip:${chipIndex}`}><ItemIcon src={item?.iconUrl} alt="" className="builder-chip-icon"/><span><small>CHIP {chipSlot + 1}</small><strong>{item?.name ?? 'EMPTY'}</strong><em>{item ? `${draft.chips[chipIndex].quality} · ${attribute(draft.chips[chipIndex].primary.statId)?.name ?? 'NO PRIMARY'}` : 'SELECT SET'}</em></span></button>{/each}</div>
</section>
</div>
{:else}
<p class="error">Select a Specialization and Focus with configured abilities before attaching Skill Chips.</p>
<p class="error">Choose a focus with 3 abilities first.</p>
{/if}
{:else if activeParts[0] === 'gear'}
{@const slot = activeParts[1]}{@const selection = draft.gear[slot]}{@const item = gearItem(slot)}
<div class="loadout-editor-head"><div><span>GEAR SLOT</span><h2>{slot.toUpperCase()}</h2></div><b>{item ? `TIER ${item.tier} · BUILD LEVEL ${selection.level}` : 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.gear.filter((entry) => entry.slot === slot)} bind:value={selection.itemId} onchange={(id) => configureGear(slot,id)} label="ITEM DEFINITION"/>
<div class="loadout-editor-head"><div><span>GEAR</span><h2>{slot.toUpperCase()}</h2></div><b>{item ? `T${item.tier} · LV ${selection.level}` : 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.gear.filter((entry) => entry.slot === slot)} bind:value={selection.itemId} onchange={(id) => configureGear(slot,id)} label="GEAR ITEM"/>
{#if item}
{@const healthRange = gearBaseRange(item, 'health', selection.level)}
{@const armorRange = item.base_armor_min !== null ? gearBaseRange(item, 'armor', selection.level) : null}
<div class="builder-equipment-controls">
<RangeValueControl label="GEAR LEVEL" min={Number(item.tier) === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeGearLevel(slot,value)}/>
<BuilderRadioGrid label="BRAND SET" name={`gear-${slot}-brand`} items={item.brandIds.map((id: string) => brand(id)).filter(Boolean)} bind:value={selection.brandId} columns={Math.min(item.brandIds.length,4)} iconOnly required/>
{#if item.talentIds.length}<BuilderRadioGrid label="EQUIPPED TALENT" name={`gear-${slot}-talent`} items={item.talentIds.map((id: string) => talent(id)).filter(Boolean)} bind:value={selection.talentId} columns={Math.min(item.talentIds.length,4)} searchable={item.talentIds.length > 6} required/>{/if}
</div>
<p class:provisional={healthRange.provisional} class="builder-scaling-note">{healthRange.provisional ? 'Estimated range: levels 1–9 remain provisional while additional game samples are collected.' : 'Base Health and Armor are calculated from the stored gear base ranges at the selected item level. Display values are truncated to match the game.'}</p>
<div class="builder-range-grid"><RangeValueControl label="BASE HEALTH" min={healthRange.min} max={healthRange.max} provisional={healthRange.provisional} bind:value={selection.health}/>{#if armorRange}<RangeValueControl label="BASE ARMOR" min={armorRange.min} max={armorRange.max} provisional={armorRange.provisional} bind:value={selection.armor}/>{/if}</div>
{@const gearCore = coreAllocationRange(Number(item.tier), selection.level, 'gear')}
<div class="builder-section-title"><div><span>CORE ATTRIBUTE ALLOCATION</span><small>SHARED T{item.tier} POOL · VALUES MAY BE REDISTRIBUTED BETWEEN CORES</small></div><b class:restricted={coreTotal(selection) < gearCore.poolMin || coreTotal(selection) > gearCore.poolMax}>{coreTotal(selection)} / {gearCore.poolMin}–{gearCore.poolMax}</b></div>
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={gearCore.poolMax} bind:value={selection[key]}/>{/each}</div>
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS · VALUES ARE LIMITED TO GEAR RANGES</small></div></div>
<div class="builder-rolls required">{#each selection.rolls as roll, index}{@const rollRange = attributeRange(attribute(roll.statId), 'gear', selection.level)}<div><BuilderPicker items={gearAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'gear',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== index).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${index + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
{@const augments = data.gearAugments.filter((entry) => entry.gear_slot === slot)}
{#if augments.length}<div class="builder-section-title"><div><span>TACTICAL AUGMENTS</span><small>ENABLE COLLECTED UPGRADES · BASE BONUSES APPLY TO THIS ITEM ONLY</small></div><b>{selection.augmentSlots.length} / {augments.length} ACTIVE</b></div><div class="builder-augment-list">{#each augments as augment}<label><input type="checkbox" checked={selection.augmentSlots.includes(Number(augment.augment_slot))} onchange={() => selection.augmentSlots = selection.augmentSlots.includes(Number(augment.augment_slot)) ? selection.augmentSlots.filter((value) => value !== Number(augment.augment_slot)) : [...selection.augmentSlots,Number(augment.augment_slot)]}/><span><b>AUGMENT 0{augment.augment_slot}</b><small>{augment.effect_type === 'Custom' ? `${attribute(augment.stat_attribute_id)?.name} · ${unitValue(augment.value,augment.stat_attribute_id)}` : `+${augment.value}% BASE HEALTH${item.base_armor_min !== null ? ' + ARMOR' : ''}`}</small></span><em>{augment.costs.find((cost: any) => Number(cost.gear_tier) === Number(item.tier))?.unlock_cost ?? '—'} COST · T{item.tier}</em></label>{/each}</div>{/if}
<div class="builder-editor-columns">
<section class="builder-editor-lane" aria-label="Gear identity and base values">
<div class="builder-equipment-controls">
<RangeValueControl label="GEAR LEVEL" min={Number(item.tier) === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeGearLevel(slot,value)}/>
<BuilderRadioGrid label="BRAND SET" name={`gear-${slot}-brand`} items={item.brandIds.map((id: string) => brand(id)).filter(Boolean)} bind:value={selection.brandId} columns={Math.min(item.brandIds.length,4)} iconOnly required/>
{#if item.talentIds.length}<BuilderRadioGrid label="EQUIPPED TALENT" name={`gear-${slot}-talent`} items={item.talentIds.map((id: string) => talent(id)).filter(Boolean)} value={selection.talentId} onchange={(id) => { selection.talentId = id; selection.talentTier = supportedTalentTier(id, selection.talentTier); }} columns={Math.min(item.talentIds.length,4)} searchable={item.talentIds.length > 6} required/>{/if}
{#if selection.talentId && talent(selection.talentId)}<TalentTierChoice name={`gear-${slot}-talent-tier`} talent={talent(selection.talentId)} bind:value={selection.talentTier}/>{/if}
</div>
<p class:provisional={healthRange.provisional} class="builder-scaling-note">{healthRange.provisional ? 'Estimated range: levels 1–9 remain provisional.' : 'Base values scale with item level and use game-matching truncated display values.'}</p>
<div class="builder-range-grid"><RangeValueControl label="BASE HEALTH" min={healthRange.min} max={healthRange.max} provisional={healthRange.provisional} bind:value={selection.health}/>{#if armorRange}<RangeValueControl label="BASE ARMOR" min={armorRange.min} max={armorRange.max} provisional={armorRange.provisional} bind:value={selection.armor}/>{/if}</div>
</section>
<section class="builder-editor-lane" aria-label="Gear attributes and augments">
<div class="builder-section-title"><div><span>CORE ALLOCATION</span><small>SHARED T{item.tier} POOL</small></div><b class:restricted={coreTotal(selection) < gearCore.poolMin || coreTotal(selection) > gearCore.poolMax}>{coreTotal(selection)} / {gearCore.poolMin}–{gearCore.poolMax}</b></div>
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={gearCore.poolMax} bind:value={selection[key]}/>{/each}</div>
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS</small></div></div>
<div class="builder-rolls required">{#each selection.rolls as roll, index}{@const rollRange = attributeRange(attribute(roll.statId), 'gear', selection.level)}<div><BuilderPicker items={gearAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'gear',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== index).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${index + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
{#if augments.length}<div class="builder-section-title"><div><span>TACTICAL AUGMENTS</span><small>OPTIONAL ITEM UPGRADES</small></div><b>{selection.augmentSlots.length} / {augments.length} ACTIVE</b></div><div class="builder-augment-list">{#each augments as augment}<label><input type="checkbox" checked={selection.augmentSlots.includes(Number(augment.augment_slot))} onchange={() => selection.augmentSlots = selection.augmentSlots.includes(Number(augment.augment_slot)) ? selection.augmentSlots.filter((value) => value !== Number(augment.augment_slot)) : [...selection.augmentSlots,Number(augment.augment_slot)]}/><span><b>AUGMENT 0{augment.augment_slot}</b><small>{augment.effect_type === 'Custom' ? `${attribute(augment.stat_attribute_id)?.name} · ${unitValue(augment.value,augment.stat_attribute_id)}` : `+${augment.value}% BASE HEALTH${item.base_armor_min !== null ? ' + ARMOR' : ''}`}</small></span><em>{augment.costs.find((cost: any) => Number(cost.gear_tier) === Number(item.tier))?.unlock_cost ?? '—'} COST · T{item.tier}</em></label>{/each}</div>{/if}
</section>
</div>
{/if}
{:else if activeParts[0] === 'weapon'}
{@const index = Number(activeParts[1])}{@const selection = draft.weapons[index]}{@const item = weaponItem(index)}{@const other = weaponItem(index ? 0 : 1)}
<div class="loadout-editor-head"><div><span>WEAPON SLOT</span><h2>{index ? 'SECONDARY' : 'PRIMARY'} WEAPON</h2></div><b>{item ? item.damage_type : 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.weapons} bind:value={selection.itemId} onchange={(id) => configureWeapon(index,id)} disabledIds={other && Number(other.is_exotic) ? data.weapons.filter((entry) => Number(entry.is_exotic)).map((entry) => entry.id) : []} label="WEAPON DEFINITION"/>
<div class="loadout-editor-head"><div><span>WEAPON</span><h2>{index ? 'SECONDARY' : 'PRIMARY'}</h2></div><b>{item ? item.damage_type : 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.weapons} bind:value={selection.itemId} onchange={(id) => configureWeapon(index,id)} disabledIds={other && Number(other.is_exotic) ? data.weapons.filter((entry) => Number(entry.is_exotic)).map((entry) => entry.id) : []} label="WEAPON"/>
{#if item}
{#if Number(item.is_exotic) && other && Number(other.is_exotic)}<p class="error">Only one exotic weapon may be equipped. Choose a standard weapon in either slot.</p>{/if}
<div class="builder-weapon-controls">
<RangeValueControl label="WEAPON LEVEL" min={selection.tier === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeWeaponLevel(index,value)}/>
<SegmentedChoice legend="TIER" name={`weapon-${index}-tier`} options={item.ranges.map((range: any) => ({ value: String(range.tier), label: `T${range.tier}` }))} value={String(selection.tier)} onchange={(values) => changeWeaponTier(index,Number(values[0]))} required/>
<div class="builder-fixed"><span>{item.rpm} RPM</span><span>{item.magazine} SHOTS</span><span>{item.reload_speed}s RELOAD</span></div>
</div>
{@const range = item.ranges.find((entry: any) => Number(entry.tier) === selection.tier)}
{#if range}
<p class:provisional={selection.level < 40} class="builder-scaling-note">{selection.level < 40 ? 'Estimated range: the weapon curve below level 40 is provisional and may change as observations are added.' : 'Damage is calculated from the stored weapon base range at the selected item level. Core attributes use the shared level-50 tier allocation pool.'}</p>
{@const damageRange = weaponStatRange(range,'damage',selection.level)}
<div class="builder-range-grid weapon-damage"><RangeValueControl label="DAMAGE" min={damageRange.min} max={damageRange.max} provisional={damageRange.provisional} bind:value={selection.damage}/></div>
{@const weaponCore = coreAllocationRange(selection.tier, selection.level, 'weapon')}
<div class="builder-section-title"><div><span>CORE ATTRIBUTE ALLOCATION</span><small>SHARED T{selection.tier} POOL · VALUES MAY BE REDISTRIBUTED BETWEEN CORES</small></div><b class:restricted={coreTotal(selection) < weaponCore.poolMin || coreTotal(selection) > weaponCore.poolMax}>{coreTotal(selection)} / {weaponCore.poolMin}–{weaponCore.poolMax}</b></div>
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={weaponCore.poolMax} bind:value={selection[key]}/>{/each}</div>
{#if range}{@const damageRange = weaponStatRange(range,'damage',selection.level)}{@const weaponCore = coreAllocationRange(selection.tier, selection.level, 'weapon')}
<div class="builder-editor-columns">
<section class="builder-editor-lane" aria-label="Weapon identity and base values">
<div class="builder-weapon-controls">
<RangeValueControl label="WEAPON LEVEL" min={selection.tier === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeWeaponLevel(index,value)}/>
<SegmentedChoice legend="TIER" name={`weapon-${index}-tier`} options={item.ranges.map((entry: any) => ({ value: String(entry.tier), label: `T${entry.tier}` }))} value={String(selection.tier)} onchange={(values) => changeWeaponTier(index,Number(values[0]))} required/>
<div class="builder-fixed"><span>{item.rpm} RPM</span><span>{item.magazine} SHOTS</span><span>{item.reload_speed}s RELOAD</span></div>
</div>
<p class:provisional={selection.level < 40} class="builder-scaling-note">{selection.level < 40 ? 'Estimated range below level 40.' : 'Damage scales with the selected item level.'}</p>
<div class="builder-range-grid weapon-damage"><RangeValueControl label="DAMAGE" min={damageRange.min} max={damageRange.max} provisional={damageRange.provisional} bind:value={selection.damage}/></div>
{#if item.baseBonuses.length}<div class="builder-inherited">{#each item.baseBonuses as bonus}<span><small>{attribute(bonus.stat_attribute_id)?.name}</small><b>{unitValue(bonus.value,bonus.stat_attribute_id)}</b></span>{/each}</div>{/if}
</section>
<section class="builder-editor-lane" aria-label="Weapon attributes and talents">
<div class="builder-section-title"><div><span>CORE ALLOCATION</span><small>SHARED T{selection.tier} POOL</small></div><b class:restricted={coreTotal(selection) < weaponCore.poolMin || coreTotal(selection) > weaponCore.poolMax}>{coreTotal(selection)} / {weaponCore.poolMin}–{weaponCore.poolMax}</b></div>
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={weaponCore.poolMax} bind:value={selection[key]}/>{/each}</div>
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS</small></div></div>
<div class="builder-rolls required">{#each selection.rolls as roll, rollIndex}{@const rollRange = attributeRange(attribute(roll.statId), 'weapon', selection.level)}<div><BuilderPicker items={weaponAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'weapon',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== rollIndex).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${rollIndex + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
<div class="builder-talent-grids">
{#if item.talent1Ids.length}<section class="builder-talent-slot"><BuilderRadioGrid label="TALENT 1" name={`weapon-${index}-talent-1`} items={item.talent1Ids.map((id: string) => talent(id)).filter(Boolean)} value={selection.talent1Id} onchange={(id) => { selection.talent1Id = id; selection.talent1Tier = supportedTalentTier(id, selection.talent1Tier); }} columns={Math.min(item.talent1Ids.length,4)} searchable={item.talent1Ids.length > 6} required/>{#if selection.talent1Id && talent(selection.talent1Id)}<TalentTierChoice name={`weapon-${index}-talent-1-tier`} talent={talent(selection.talent1Id)} bind:value={selection.talent1Tier}/>{/if}</section>{/if}
{#if item.talent2Ids.length}<section class="builder-talent-slot"><BuilderRadioGrid label="TALENT 2" name={`weapon-${index}-talent-2`} items={item.talent2Ids.map((id: string) => talent(id)).filter(Boolean)} value={selection.talent2Id} onchange={(id) => { selection.talent2Id = id; selection.talent2Tier = supportedTalentTier(id, selection.talent2Tier); }} columns={Math.min(item.talent2Ids.length,4)} searchable={item.talent2Ids.length > 6} required/>{#if selection.talent2Id && talent(selection.talent2Id)}<TalentTierChoice name={`weapon-${index}-talent-2-tier`} talent={talent(selection.talent2Id)} bind:value={selection.talent2Tier}/>{/if}</section>{/if}
{#if item.exotic_talent_id && talent(item.exotic_talent_id)}<section class="builder-talent-slot builder-exotic-talent"><div><ItemIcon src={talent(item.exotic_talent_id).iconUrl} alt=""/><span><small>EXOTIC TALENT</small><b>{talent(item.exotic_talent_id).name}</b></span></div><TalentTierChoice name={`weapon-${index}-exotic-talent-tier`} talent={talent(item.exotic_talent_id)} bind:value={selection.exoticTalentTier}/></section>{/if}
</div>
</section>
</div>
{/if}
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS · VALUES ARE LIMITED TO WEAPON RANGES</small></div></div>
<div class="builder-rolls required">{#each selection.rolls as roll, rollIndex}{@const rollRange = attributeRange(attribute(roll.statId), 'weapon', selection.level)}<div><BuilderPicker items={weaponAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'weapon',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== rollIndex).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${rollIndex + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
<div class="builder-talent-grids">
{#if item.talent1Ids.length}<BuilderRadioGrid label="TALENT 1" name={`weapon-${index}-talent-1`} items={item.talent1Ids.map((id: string) => talent(id)).filter(Boolean)} bind:value={selection.talent1Id} columns={Math.min(item.talent1Ids.length,4)} searchable={item.talent1Ids.length > 6} required/>{/if}
{#if item.talent2Ids.length}<BuilderRadioGrid label="TALENT 2" name={`weapon-${index}-talent-2`} items={item.talent2Ids.map((id: string) => talent(id)).filter(Boolean)} bind:value={selection.talent2Id} columns={Math.min(item.talent2Ids.length,4)} searchable={item.talent2Ids.length > 6} required/>{/if}
</div>
{#if item.baseBonuses.length}<div class="builder-inherited">{#each item.baseBonuses as bonus}<span><small>{attribute(bonus.stat_attribute_id)?.name}</small><b>{unitValue(bonus.value,bonus.stat_attribute_id)}</b></span>{/each}</div>{/if}
{/if}
{:else if activeParts[0] === 'chip'}
{@const index = Number(activeParts[1])}{@const chip = draft.chips[index]}{@const item = chipItem(index)}{@const abilityIndex = Math.floor(index / 3)}{@const ability = selectedFocus?.abilities[abilityIndex]}
<div class="loadout-editor-head"><div><span>ABILITY {abilityIndex + 1} · CHIP {(index % 3) + 1}</span><h2>{ability?.name?.toUpperCase() ?? 'SKILL CHIP'}</h2></div><b>{chip.quality.toUpperCase()}</b></div>
<button class="builder-return-link" type="button" onclick={() => active = `ability:${abilityIndex}`}>← RETURN TO {ability?.name?.toUpperCase() ?? 'ABILITY'}</button>
{#if ability}<BuilderPicker items={data.chipSets.filter((entry: any) => entry.specialization_ability_id === ability.id)} bind:value={chip.setId} onchange={(id) => configureChip(index,id)} label="ABILITY-COMPATIBLE SKILL CHIP SET"/>{:else}<p class="error">Select a focus with three configured class abilities before equipping Skill Chips.</p>{/if}
{#if item}<div class="builder-field-grid"><label>QUALITY<select bind:value={chip.quality}><option>Superior</option><option>High-end</option></select></label></div><div class="builder-chip-rolls"><div><BuilderPicker items={chipAttributes.filter((entry) => item.primaryStatIds.includes(entry.id))} bind:value={chip.primary.statId} onchange={(id) => setRollStat(chip.primary,id,'chip')} label="PRIMARY ATTRIBUTE"/><label>VALUE<input type="number" min={attribute(chip.primary.statId)?.skill_chip_min ?? 0} max={attribute(chip.primary.statId)?.skill_chip_max ?? 0} bind:value={chip.primary.value} onblur={() => chip.primary.value = clamp(chip.primary.value,attribute(chip.primary.statId)?.skill_chip_min,attribute(chip.primary.statId)?.skill_chip_max)}/></label></div>{#each chip.secondary as roll,index}<div><BuilderPicker items={chipAttributes.filter((entry) => item.secondaryStatIds.includes(entry.id))} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'chip')} disabledIds={chip.secondary.filter((_,i) => i !== index).map((entry) => entry.statId)} label={`SECONDARY ATTRIBUTE ${index + 1}`}/><label>VALUE<input type="number" min={attribute(roll.statId)?.skill_chip_min ?? 0} max={attribute(roll.statId)?.skill_chip_max ?? 0} bind:value={roll.value} onblur={() => roll.value = clamp(roll.value,attribute(roll.statId)?.skill_chip_min,attribute(roll.statId)?.skill_chip_max)}/></label></div>{/each}</div>{/if}
<div class="loadout-editor-head"><div><span>CHIP {(index % 3) + 1} / 3</span><h2>{ability?.name?.toUpperCase() ?? 'SKILL CHIP'}</h2></div><b>{chip.quality.toUpperCase()}</b></div>
<button class="builder-return-link" type="button" onclick={() => active = `ability:${abilityIndex}`}>← BACK TO ABILITY</button>
{#if ability}<BuilderPicker items={data.chipSets.filter((entry: any) => chipSupportsAbility(entry, ability.id))} bind:value={chip.setId} onchange={(id) => configureChip(index,id)} label="CHIP SET"/>{:else}<p class="error">Choose a focus with 3 abilities first.</p>{/if}
{#if !chipProfileConfigured}<p class="error">CHIP PROFILE REQUIRED. Configure shared ranges in ISAC.</p>{/if}
{#if item}
<div class="builder-editor-columns builder-chip-editor">
<section class="builder-editor-lane" aria-label="Skill Chip quality and primary Attribute">
<div class="builder-field-grid"><SegmentedChoice legend="QUALITY" name={`chip-${index}-quality`} options={skillChipQualities.map((quality) => ({ value: quality, label: quality.toUpperCase() }))} value={chip.quality} onchange={(values) => changeChipQuality(index, values[0] as SkillChipQuality)} required/></div>
<div class="builder-section-title"><div><span>PRIMARY</span><small>ABILITY ONLY</small></div><b>NOT IN TOTALS</b></div>
<p class="builder-scaling-note">Not included in totals.</p>
<div class="builder-chip-rolls"><div><BuilderPicker items={chipPrimaryAttributes} bind:value={chip.primary.statId} onchange={(id) => setChipRoll(chip.primary,id,'primary',chip.quality)} label="PRIMARY"/>{#if validChipRange(chipRange('primary', chip.primary.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('primary', chip.primary.statId, chip.quality)} max={chipRangeMax('primary', chip.primary.statId, chip.quality)} step={0.01} bind:value={chip.primary.value}/>{:else}<p class="error">PRIMARY RANGE REQUIRED</p>{/if}</div></div>
</section>
<section class="builder-editor-lane" aria-label="Skill Chip secondary Attributes">
<div class="builder-section-title"><div><span>SECONDARIES</span><small>{skillChipSecondaryCapacity[chip.quality]} SLOT{skillChipSecondaryCapacity[chip.quality] === 1 ? '' : 'S'}</small></div></div>
<div class="builder-chip-rolls">{#each chip.secondary as roll, secondaryIndex}<div><BuilderPicker items={chipSecondaryAttributes} bind:value={roll.statId} onchange={(id) => setChipRoll(roll,id,'secondary',chip.quality)} disabledIds={chip.secondary.filter((_,i) => i !== secondaryIndex).map((entry) => entry.statId).filter(Boolean)} label={`SECONDARY ${secondaryIndex + 1}`}/>{#if validChipRange(chipRange('secondary', roll.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('secondary', roll.statId, chip.quality)} max={chipRangeMax('secondary', roll.statId, chip.quality)} step={0.01} bind:value={roll.value}/>{:else}<p class="error">SECONDARY RANGE REQUIRED</p>{/if}</div>{/each}</div>
{#if skillChipSecondaryCapacity[chip.quality] === 0}<p class="builder-scaling-note">NO SECONDARIES</p>{/if}
</section>
</div>
{/if}
{:else}
{@const protocol = protocolItem()}
<div class="loadout-editor-head"><div><span>AGENT SYSTEM</span><h2>OS PROTOCOL</h2></div><b>{protocol?.quality?.toUpperCase() ?? 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.protocols} bind:value={draft.protocol.itemId} label="OS PROTOCOL"/>
<div class="loadout-editor-head"><div><span>PROTOCOL</span><h2>OS PROTOCOL</h2></div><b>{protocol?.quality?.toUpperCase() ?? 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.protocols} bind:value={draft.protocol.itemId} label="PROTOCOL"/>
{#if protocol}<div class="builder-range-grid"><label>PROTOCOL LEVEL <span>01–50</span><input type="number" min="1" max="50" bind:value={draft.protocol.level} onblur={() => draft.protocol.level = clamp(draft.protocol.level,1,50)}/></label><label>TALENT UPGRADES <span>0–5</span><input type="number" min="0" max="5" bind:value={draft.protocol.talentUpgrades} onblur={() => draft.protocol.talentUpgrades = clamp(draft.protocol.talentUpgrades,0,5)}/></label></div><div class="builder-inherited">{#if protocol.primaryEffect}<span><small>{attribute(protocol.primaryEffect.stat_attribute_id)?.name} · LEVEL {draft.protocol.level}</small><b>{unitValue(Number(protocol.primaryEffect.base_value)*(1+(draft.protocol.level-1)/49),protocol.primaryEffect.stat_attribute_id)}</b></span>{/if}{#each protocol.secondaryEffects as effect}<span><small>{attribute(effect.stat_attribute_id)?.name}</small><b>{unitValue(effect.value,effect.stat_attribute_id)}</b></span>{/each}</div>{/if}
{/if}
</section>
<aside class="loadout-summary">
<LoadoutStatsSummary {summary} attributes={data.attributes} talents={data.talents} headingId="builder-known-stats-heading" />
<WeaponBaselineThroughput entries={weaponBaselines} headingId="builder-weapon-output-heading" />
<div class="builder-planned-model"><PlannedCombatModel headingId="builder-planned-output-heading" variant="compact" /></div>
<p class="notice">Anonymous drafts remain on this device. Account saves preserve the equipped-item configuration and current visibility.</p>
</aside>
+290
View File
@@ -0,0 +1,290 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createDraft, type LoadoutDraft } from '$lib/builder/loadout';
const mocks = vi.hoisted(() => ({
insertBuild: vi.fn(),
getSessionUser: vi.fn(async () => ({
id: 'agent-1',
displayName: 'Test Agent',
})),
database: {
prepare: vi.fn(),
},
}));
vi.mock('$lib/server/auth/session', () => ({
getSessionUser: mocks.getSessionUser,
}));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => mocks.database),
}));
import { actions } from './+page.server';
const primaryStatId = 'skill-duration';
const secondaryStatIds = ['critical-hit-chance', 'critical-hit-damage'];
const validLoadout = (): LoadoutDraft => {
const loadout = createDraft();
loadout.buildName = 'Validated Skill Chips';
loadout.specialization.focusId = 'focus-1';
loadout.chips[0] = {
setId: 'chip-set-1',
quality: 'Superior',
primary: { statId: primaryStatId, value: 15 },
secondary: [
{ statId: secondaryStatIds[0], value: 2 },
{ statId: secondaryStatIds[1], value: 4 },
],
};
return loadout;
};
const saveRequest = (loadout: LoadoutDraft) => {
const form = new FormData();
form.set('loadout', JSON.stringify(loadout));
form.set('visibility', 'private');
return new Request('http://localhost/builder?/save', {
method: 'POST',
body: form,
});
};
const runSave = (loadout: LoadoutDraft) =>
actions.save?.({
cookies: {} as never,
request: saveRequest(loadout),
} as never);
describe('Builder Skill Chip save validation', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.database.prepare.mockImplementation(async (sql: string) => ({
all: vi.fn(async () => {
if (sql.includes('FROM skill_chip_sets'))
return [
{
id: 'chip-set-1',
specialization_ability_id: 'ability-1',
},
];
if (sql.includes('FROM specialization_focus_abilities'))
return [{ ability_id: 'ability-1', ability_slot: 1 }];
if (sql.includes('FROM skill_chip_primary_options'))
return [{ stat_attribute_id: primaryStatId }];
if (sql.includes('FROM skill_chip_primary_quality_values'))
return [
{
stat_attribute_id: primaryStatId,
quality: 'Superior',
min_value: 10,
max_value: 20,
},
];
if (sql.includes('FROM skill_chip_secondary_options'))
return secondaryStatIds.map((stat_attribute_id) => ({
stat_attribute_id,
}));
if (sql.includes('FROM skill_chip_secondary_quality_values'))
return secondaryStatIds.map((stat_attribute_id) => ({
stat_attribute_id,
quality: 'Superior',
min_value: 1,
max_value: 5,
}));
return [];
}),
run: mocks.insertBuild,
}));
});
it('rejects a selected Skill Chip assigned to a different focus ability', async () => {
mocks.database.prepare.mockImplementation(async (sql: string) => ({
all: vi.fn(async () => {
if (sql.includes('FROM skill_chip_sets'))
return [
{
id: 'chip-set-1',
specialization_ability_id: 'ability-2',
},
];
if (sql.includes('FROM specialization_focus_abilities'))
return [{ ability_id: 'ability-1', ability_slot: 1 }];
return [];
}),
run: mocks.insertBuild,
}));
const result = await runSave(validLoadout());
expect(result).toMatchObject({
status: 400,
data: {
buildError: 'Skill Chip 1 is not compatible with its assigned ability.',
},
});
expect(mocks.insertBuild).not.toHaveBeenCalled();
});
it('accepts a Skill Chip owned by an explicitly compatible ability', async () => {
mocks.database.prepare.mockImplementation(async (sql: string) => ({
all: vi.fn(async () => {
if (sql.includes('FROM skill_chip_sets'))
return [
{
id: 'chip-set-1',
specialization_ability_id: 'ability-2',
},
];
if (sql.includes('FROM specialization_ability_skill_chip_links'))
return [
{
ability_id: 'ability-1',
compatible_ability_id: 'ability-2',
},
];
if (sql.includes('FROM specialization_focus_abilities'))
return [{ ability_id: 'ability-1', ability_slot: 1 }];
if (sql.includes('FROM skill_chip_primary_options'))
return [{ stat_attribute_id: primaryStatId }];
if (sql.includes('FROM skill_chip_primary_quality_values'))
return [
{
stat_attribute_id: primaryStatId,
quality: 'Superior',
min_value: 10,
max_value: 20,
},
];
if (sql.includes('FROM skill_chip_secondary_options'))
return secondaryStatIds.map((stat_attribute_id) => ({
stat_attribute_id,
}));
if (sql.includes('FROM skill_chip_secondary_quality_values'))
return secondaryStatIds.map((stat_attribute_id) => ({
stat_attribute_id,
quality: 'Superior',
min_value: 1,
max_value: 5,
}));
return [];
}),
run: mocks.insertBuild,
}));
const result = await runSave(validLoadout());
expect(result).toMatchObject({ buildSaved: true });
expect(mocks.insertBuild).toHaveBeenCalledOnce();
});
it.each([
{
label: 'primary',
mutate: (loadout: LoadoutDraft) => {
loadout.chips[0].primary.value = 21;
},
message: 'Skill Chip 1 has an invalid primary Attribute value.',
},
{
label: 'secondary',
mutate: (loadout: LoadoutDraft) => {
loadout.chips[0].secondary[1].value = 6;
},
message: 'Skill Chip 1 has an invalid secondary Attribute value.',
},
])('rejects an out-of-range $label value', async ({ mutate, message }) => {
const loadout = validLoadout();
mutate(loadout);
const result = await runSave(loadout);
expect(result).toMatchObject({
status: 400,
data: { buildError: message },
});
expect(mocks.insertBuild).not.toHaveBeenCalled();
});
it('rejects duplicate secondary Attributes', async () => {
const loadout = validLoadout();
loadout.chips[0].secondary[1].statId = secondaryStatIds[0];
const result = await runSave(loadout);
expect(result).toMatchObject({
status: 400,
data: {
buildError: 'Skill Chip 1 must use distinct secondary Attributes.',
},
});
expect(mocks.insertBuild).not.toHaveBeenCalled();
});
it('accepts primary and secondary values within the configured profiles', async () => {
const result = await runSave(validLoadout());
expect(result).toMatchObject({
buildSaved: true,
buildToken: expect.any(String),
savedBuild: {
visibility: 'private',
title: 'Validated Skill Chips',
},
});
expect(mocks.insertBuild).toHaveBeenCalledOnce();
});
it('rejects an equipment Talent tier without canonical tier data', async () => {
const loadout = createDraft();
loadout.buildName = 'Invalid Talent Tier';
loadout.gear.Holster.itemId = 'gear-1';
loadout.gear.Holster.talentId = 'talent-1';
loadout.gear.Holster.talentTier = 2;
mocks.database.prepare.mockImplementation(async (sql: string) => ({
all: vi.fn(async () => {
if (sql.includes('FROM gear_talents'))
return [{ gear_id: 'gear-1', talent_id: 'talent-1' }];
if (sql.includes('FROM talent_tiers'))
return [{ talent_id: 'talent-1', tier: 'I' }];
return [];
}),
run: mocks.insertBuild,
}));
const result = await runSave(loadout);
expect(result).toMatchObject({
status: 400,
data: { buildError: 'Holster Talent T2 is not configured.' },
});
expect(mocks.insertBuild).not.toHaveBeenCalled();
});
it('saves an equipment Talent with an authored Tier II', async () => {
const loadout = createDraft();
loadout.buildName = 'Tier Two Talent';
loadout.gear.Holster.itemId = 'gear-1';
loadout.gear.Holster.talentId = 'talent-1';
loadout.gear.Holster.talentTier = 2;
mocks.database.prepare.mockImplementation(async (sql: string) => ({
all: vi.fn(async () => {
if (sql.includes('FROM gear_talents'))
return [{ gear_id: 'gear-1', talent_id: 'talent-1' }];
if (sql.includes('FROM talent_tiers'))
return [
{ talent_id: 'talent-1', tier: 'I' },
{ talent_id: 'talent-1', tier: 'II' },
];
return [];
}),
run: mocks.insertBuild,
}));
const result = await runSave(loadout);
expect(result).toMatchObject({ buildSaved: true });
expect(mocks.insertBuild).toHaveBeenCalledOnce();
});
});
+4 -3
View File
@@ -10,6 +10,7 @@
const chip = (id: string) => data.chips.find((item) => item.id === id);
const protocol = (id: string) => data.protocols.find((item) => item.id === id);
const brand = (id: string) => data.brands.find((item) => item.id === id);
const talent = (id: string) => data.talents.find((item) => item.id === id);
const attribute = (id: string) => data.attributes.find((item) => item.id === id);
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
const rollLine = (rolls: Array<{ statId: string; value: number }>) => rolls
@@ -56,15 +57,15 @@
<div class="shared-build-grid">
<section>
<header><span>WEAPONS</span><b>02 SLOTS</b></header>
{#each data.loadout.weapons as selection, index}{@const item=weapon(selection.itemId)}<article><small>{index ? 'SECONDARY' : 'PRIMARY'}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${item.category} · ${item.damage_type} · T${selection.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span>{#if item}<div class="shared-item-facts"><b>{number.format(selection.damage)} DAMAGE</b><b>{item.rpm} RPM</b><b>{item.magazine} ROUNDS</b><b>{item.reload_speed}s RELOAD</b></div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</article>{/each}
{#each data.loadout.weapons as selection, index}{@const item=weapon(selection.itemId)}<article><small>{index ? 'SECONDARY' : 'PRIMARY'}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${item.category} · ${item.damage_type} · T${selection.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span>{#if item}<div class="shared-item-facts"><b>{number.format(selection.damage)} DAMAGE</b><b>{item.rpm} RPM</b><b>{item.magazine} ROUNDS</b><b>{item.reload_speed}s RELOAD</b>{#if selection.talent1Id}<b>{talent(selection.talent1Id)?.name ?? 'TALENT 1'} · T{selection.talent1Tier}</b>{/if}{#if selection.talent2Id}<b>{talent(selection.talent2Id)?.name ?? 'TALENT 2'} · T{selection.talent2Tier}</b>{/if}{#if item.exotic_talent_id}<b>{talent(item.exotic_talent_id)?.name ?? 'EXOTIC TALENT'} · T{selection.exoticTalentTier}</b>{/if}</div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</article>{/each}
</section>
<section>
<header><span>GEAR</span><b>06 SLOTS</b></header>
{#each gearSlots as slot}{@const selection=data.loadout.gear[slot]}{@const item=gear(selection.itemId)}<article><small>{slot.toUpperCase()}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${brand(selection.brandId)?.name ?? 'NO BRAND'} · T${item.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span>{#if item}<div class="shared-item-facts"><b>{number.format(selection.health)} HEALTH</b>{#if item.base_armor_min !== null}<b>{number.format(selection.armor)} ARMOR</b>{/if}</div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</article>{/each}
{#each gearSlots as slot}{@const selection=data.loadout.gear[slot]}{@const item=gear(selection.itemId)}<article><small>{slot.toUpperCase()}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${brand(selection.brandId)?.name ?? 'NO BRAND'} · T${item.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span>{#if item}<div class="shared-item-facts"><b>{number.format(selection.health)} HEALTH</b>{#if item.base_armor_min !== null}<b>{number.format(selection.armor)} ARMOR</b>{/if}{#if selection.talentId}<b>{talent(selection.talentId)?.name ?? 'TALENT'} · T{selection.talentTier}</b>{/if}</div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</article>{/each}
</section>
<section>
<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>
+94 -34
View File
@@ -40,6 +40,10 @@ import { recordIsacAudit } from '$lib/server/audit';
import { removePublicComment } from '$lib/server/comment-removal';
import { createNotification } from '$lib/server/notifications';
import type { Actions } from './$types';
import {
publicWeaponModSlots,
weaponModSlotDefinitionByName,
} from '$lib/weapons/mod-slots';
const number = (value: unknown) => {
const numeric = Number(value);
@@ -336,8 +340,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
rowsSql = `SELECT item.*,render.id AS render_id,render.updated_at AS render_updated_at,
native.damage_min,native.damage_max,native.reference_level,
(SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=1) AS talent_1_count,
(SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=2) AS talent_2_count,
(SELECT COUNT(*) FROM weapon_category_attribute_bonuses bonuses WHERE bonuses.weapon_category=item.category) AS bonus_count
(SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=2) AS talent_2_count
FROM weapons item
LEFT JOIN weapon_tier_ranges native ON native.weapon_id=item.id AND native.tier=item.native_tier
LEFT JOIN managed_images render ON render.source_entity_type='weapon_render' AND render.source_entity_id=item.id
@@ -386,7 +389,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
rowsSql = `SELECT item.*,weapons.name AS exotic_weapon,
render.id AS render_id,render.updated_at AS render_updated_at,
GROUP_CONCAT(DISTINCT categories.weapon_category) AS categories,
(SELECT COUNT(*) FROM weapon_mod_secondary_options secondary WHERE secondary.weapon_mod_id=item.id) AS secondary_count,
(SELECT COUNT(*) FROM weapon_mod_secondary_options) AS secondary_count,
(SELECT COUNT(*) FROM weapon_mod_effects effects WHERE effects.weapon_mod_id=item.id) AS effect_count
FROM weapon_mods item
LEFT JOIN weapons ON weapons.id=item.exotic_weapon_id
@@ -398,7 +401,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
case 'gear-mods':
countSql = `SELECT COUNT(*) AS total FROM gear_mods item WHERE item.active=1${searchClause}`;
rowsSql = `SELECT item.*,render.id AS render_id,render.updated_at AS render_updated_at,
(SELECT COUNT(*) FROM gear_mod_secondary_options secondary WHERE secondary.gear_mod_id=item.id) AS secondary_count,
(SELECT COUNT(*) FROM gear_mod_secondary_options) AS secondary_count,
(SELECT MIN(primary_level_1_min) FROM gear_mod_quality_values quality WHERE quality.gear_mod_id=item.id) AS level_1_floor,
(SELECT MAX(primary_level_20_max) FROM gear_mod_quality_values quality WHERE quality.gear_mod_id=item.id) AS level_20_ceiling
FROM gear_mods item
@@ -409,7 +412,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
case 'os-protocols':
countSql = `SELECT COUNT(*) AS total FROM os_protocols item WHERE item.active=1${searchClause}`;
rowsSql = `SELECT item.*,attributes.name AS primary_stat,attributes.unit AS primary_unit,
primary_effect.base_value,talents.name AS talent_name,icons.updated_at AS icon_updated_at,
primary_effect.base_value,talents.name AS talent_name,talents.description_template AS talent_description,icons.updated_at AS icon_updated_at,
render.id AS render_id,render.updated_at AS render_updated_at
FROM os_protocols item
LEFT JOIN os_protocol_primary_effects primary_effect ON primary_effect.protocol_id=item.id
@@ -500,8 +503,8 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE item.active=1${searchClause}`;
rowsSql = `SELECT item.*,bonus.min_value,bonus.max_value,attributes.name AS bonus_stat,attributes.unit AS bonus_unit,
talents.name AS talent_name,abilities.name AS ability_name,specializations.name AS specialization_name,
(SELECT COUNT(*) FROM skill_chip_primary_options options WHERE options.skill_chip_set_id=item.id) AS primary_count,
(SELECT COUNT(*) FROM skill_chip_secondary_options options WHERE options.skill_chip_set_id=item.id) AS secondary_count,
(SELECT COUNT(*) FROM skill_chip_primary_options) AS primary_count,
(SELECT COUNT(*) FROM skill_chip_secondary_options) AS secondary_count,
icons.updated_at AS icon_updated_at,render.id AS render_id,render.updated_at AS render_updated_at
FROM skill_chip_sets item
LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id
@@ -549,6 +552,17 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
const rows = await (
await database.prepare(rowsSql)
).all(...queryArgs, pagination.perPage, pagination.offset);
const protocolTalentEffects =
category.slug === 'os-protocols' && rows.length
? await (
await database.prepare(`SELECT talents.protocol_id,effects.base_value,attributes.unit
FROM os_protocol_talents talents
JOIN os_protocol_talent_effects effects ON effects.talent_id=talents.id
JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id
WHERE talents.protocol_id IN (${rows.map(() => '?').join(',')})
ORDER BY talents.protocol_id,effects.sort_order`)
).all(...rows.map((row) => row.id))
: [];
const weaponRelations =
category.slug === 'weapons' && rows.length
? await (
@@ -563,6 +577,22 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
ORDER BY options.talent_slot,talents.name COLLATE NOCASE`)
).all(...rows.map((row) => row.id))
: [];
const weaponModSlotRows =
category.slug === 'weapons' && rows.length
? await (
await database.prepare(`SELECT weapon_id,tier,slot_type FROM weapon_mod_slots
WHERE weapon_id IN (${rows.map(() => '?').join(',')})
ORDER BY weapon_id,tier,slot_type`)
).all(...rows.map((row) => row.id))
: [];
const weaponModSlotIconRows =
category.slug === 'weapons'
? await (
await database.prepare(
"SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='weapon_mod_slot'",
)
).all()
: [];
const gearRelations =
category.slug === 'gear' && rows.length
? await (
@@ -769,6 +799,36 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
const relatedTalents = weaponRelations.filter(
(relation) => relation.weapon_id === row.id,
);
const modSlots = publicWeaponModSlots(
Number(row.native_tier),
[0, 1, 2].map((tier) => ({
tier,
slots: weaponModSlotRows
.filter(
(slot) =>
slot.weapon_id === row.id && Number(slot.tier) === tier,
)
.map((slot) => String(slot.slot_type)),
})),
).map((slot) => {
const definition = weaponModSlotDefinitionByName(slot.slot);
const icon = definition
? weaponModSlotIconRows.find(
(row) => row.entity_id === definition.id,
)
: null;
return {
...slot,
iconUrl:
definition && icon
? gameDataIconUrl(
'weapon_mod_slot',
definition.id,
icon.updated_at,
)
: '',
};
});
const talentItems = (slot: number) =>
relatedTalents
.filter((relation) => Number(relation.talent_slot) === slot)
@@ -801,11 +861,11 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
? managedImageUrl(String(row.render_id), row.render_updated_at)
: '',
gameIcon: weaponIcon(row.category),
headerBadge: `T${row.native_tier}`,
headerBadges: [
`T${row.native_tier}`,
...(Number(row.is_exotic) ? ['EXOTIC'] : []),
],
headerMetaIcon: weaponIcon(row.damage_type),
badges: Number(row.is_exotic)
? [{ label: 'EXOTIC', tone: 'exotic' }]
: [],
factLayout: 'stack' as const,
facts: [
{
@@ -831,7 +891,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
],
},
],
lines: [{ label: 'BASE BONUSES', value: String(row.bonus_count) }],
weaponModSlots: modSlots,
iconGroups: [
{
label: 'TALENT 1 POOL',
@@ -914,28 +974,28 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
? managedImageUrl(String(row.render_id), row.render_updated_at)
: '',
iconUrl: iconUrl('os_protocol', row.id, row.icon_updated_at),
badges: [
{
label: String(row.quality),
tone: String(row.quality).toLowerCase().replace('-', ''),
},
],
facts: [
{ label: 'LEVELS', value: '01–50' },
{
label: 'PRIMARY EFFECT',
value: row.primary_stat
? `${row.primary_stat} · ${valueWithUnit(row.base_value, row.primary_unit)}`
: 'NOT CONFIGURED',
},
{ label: 'TALENT UPGRADES', value: '0–5' },
],
lines: [
{
label: 'UNIQUE TALENT',
value: list(row.talent_name, 'NOT CONFIGURED'),
},
],
topBorderTone: String(row.quality).toLowerCase().replace('-', ''),
headerBadge: String(row.quality).toUpperCase(),
headerBadgeTone: String(row.quality).toLowerCase().replace('-', ''),
factLayout: 'stack' as const,
facts: [],
protocolPreview: {
primaryStat: String(row.primary_stat ?? 'NOT CONFIGURED'),
primaryUnit: String(row.primary_unit ?? ''),
primaryBaseValue: Number(row.base_value),
talentDescription: row.talent_description
? String(row.talent_description)
: undefined,
talentEffects: protocolTalentEffects
.filter((effect) => effect.protocol_id === row.id)
.map((effect) => ({
baseValue: Number(effect.base_value),
unit: String(effect.unit ?? ''),
})),
},
description: row.talent_description
? String(row.talent_description)
: undefined,
};
case 'talents':
return talentCardItem(row, mechanicsFor(row.id));
+6 -15
View File
@@ -34,8 +34,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'gearMax',
'weaponMin',
'weaponMax',
'chipMin',
'chipMax',
'capValue',
],
},
@@ -47,8 +45,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
gear_max: stat.gearMax || null,
weapon_min: stat.weaponMin || null,
weapon_max: stat.weaponMax || null,
skill_chip_min: stat.chipMin || null,
skill_chip_max: stat.chipMax || null,
cap_value: stat.capValue || null,
}));
const summary = await (
@@ -211,14 +207,9 @@ export const actions: Actions = {
let values: Array<number | null>;
try {
values = [
...[
'gearMin',
'gearMax',
'weaponMin',
'weaponMax',
'chipMin',
'chipMax',
].map((key) => number(key)),
...['gearMin', 'gearMax', 'weaponMin', 'weaponMax'].map((key) =>
number(key),
),
number('capValue', false),
];
} catch {
@@ -226,7 +217,7 @@ export const actions: Actions = {
error: 'Ranges must be numbers. Optional caps cannot be negative.',
});
}
for (let index = 0; index < 6; index += 2)
for (let index = 0; index < 4; index += 2)
if (
(values[index] === null) !== (values[index + 1] === null) ||
(values[index] !== null &&
@@ -253,7 +244,7 @@ export const actions: Actions = {
if (id)
await (
await database.prepare(
`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,updated_at=? WHERE id=?`,
`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,cap_value=?,updated_at=? WHERE id=?`,
)
).run(
name,
@@ -269,7 +260,7 @@ export const actions: Actions = {
else
await (
await database.prepare(
`INSERT INTO stat_attributes(id,name,description,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
`INSERT INTO stat_attributes(id,name,description,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
)
).run(
statId,
+2 -2
View File
@@ -14,7 +14,7 @@
const categoryOptions = ['Weapon', 'Skill', 'Character'].map((value) => ({ value, label: value }));
const calculationOptions = ['Additive', 'Multiplicative'].map((value) => ({ value, label: value }));
const unitOptions = [{value:'',label:'None'},{value:'%',label:'%'},{value:'s',label:'s'},{value:'Shots',label:'Shots'},{value:'m',label:'m'}];
const blank = () => ({id:'',name:'',description:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',skill_chip_min:'',skill_chip_max:'',cap_value:''});
const blank = () => ({id:'',name:'',description:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',cap_value:''});
const value = (input: unknown) => input ?? '';
const attributeMeta = (stat: any) => stat.pending
? `PROPOSED BY ${stat.proposerName}`
@@ -32,7 +32,7 @@
</script>
{#snippet attributeEditor()}
{#if editing}
<div class="dashboard-editor attribute-editor"><div class="editor-title"><div><p class="eyebrow">{editing.id?'MODIFY RECORD':'NEW RECORD'}</p><h3>{editing.id?editing.name:'ATTRIBUTE DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing=null}>×</button></div><PendingForm action="?/save" class="grid"><input type="hidden" name="id" value={editing.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={editing.name} required /></label><SegmentedChoice legend="TYPE" name="type" options={typeOptions} bind:value={editing.type} required/><SegmentedChoice legend="CATEGORY" name="category" options={categoryOptions} bind:value={editing.category} required/><SegmentedChoice legend="CALCULATION" name="calculationType" options={calculationOptions} bind:value={editing.calculation_type} required/><SegmentedChoice legend="UNIT" name="unit" options={unitOptions} bind:value={editing.unit}/><label class="field attribute-description">DESCRIPTION · OPTIONAL<textarea name="description" bind:value={editing.description} maxlength="1200" placeholder="Explain what this Attribute represents and how players encounter it."></textarea></label>{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}<div class="field range-field"><span class="label">{source[1]} RANGE · OPTIONAL</span><div><input name={`${source[0]==='skill_chip'?'chip':source[0]}Min`} type="number" step="any" value={value(editing[`${source[0]}_min`])} placeholder="MIN"/><input name={`${source[0]==='skill_chip'?'chip':source[0]}Max`} type="number" step="any" value={value(editing[`${source[0]}_max`])} placeholder="MAX"/></div></div>{/each}<label class="field">OPTIONAL CAP<input name="capValue" type="number" step="any" value={value(editing.cap_value)}/></label></div>{#if form?.error}<p class="error">{form.error}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => editing=null}>CANCEL</button><button class="primary">SAVE ATTRIBUTE →</button></div></PendingForm></div>
<div class="dashboard-editor attribute-editor"><div class="editor-title"><div><p class="eyebrow">{editing.id?'MODIFY RECORD':'NEW RECORD'}</p><h3>{editing.id?editing.name:'ATTRIBUTE DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing=null}>×</button></div><PendingForm action="?/save" class="grid"><input type="hidden" name="id" value={editing.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={editing.name} required /></label><SegmentedChoice legend="TYPE" name="type" options={typeOptions} bind:value={editing.type} required/><SegmentedChoice legend="CATEGORY" name="category" options={categoryOptions} bind:value={editing.category} required/><SegmentedChoice legend="CALCULATION" name="calculationType" options={calculationOptions} bind:value={editing.calculation_type} required/><SegmentedChoice legend="UNIT" name="unit" options={unitOptions} bind:value={editing.unit}/><label class="field attribute-description">DESCRIPTION · OPTIONAL<textarea name="description" bind:value={editing.description} maxlength="1200" placeholder="Explain what this Attribute represents and how players encounter it."></textarea></label>{#each [['gear','GEAR'],['weapon','WEAPON']] as source}<div class="field range-field"><span class="label">{source[1]} RANGE · OPTIONAL</span><div><input name={`${source[0]}Min`} type="number" step="any" value={value(editing[`${source[0]}_min`])} placeholder="MIN"/><input name={`${source[0]}Max`} type="number" step="any" value={value(editing[`${source[0]}_max`])} placeholder="MAX"/></div></div>{/each}<label class="field">OPTIONAL CAP<input name="capValue" type="number" step="any" value={value(editing.cap_value)}/></label></div>{#if form?.error}<p class="error">{form.error}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => editing=null}>CANCEL</button><button class="primary">SAVE ATTRIBUTE →</button></div></PendingForm></div>
{/if}
{/snippet}
<svelte:head><title>Attributes — SHD.build admin</title></svelte:head>
+1 -3
View File
@@ -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(),
+43 -20
View File
@@ -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 = [
@@ -92,6 +98,14 @@ async function specializationProposalSnapshot(database: any, proposal: any) {
'SELECT * FROM specialization_ability_effects WHERE ability_id=? ORDER BY effect_order',
)
).all(id),
skillChipLinks: await (
await database.prepare(
`SELECT ability_id,compatible_ability_id
FROM specialization_ability_skill_chip_links
WHERE ability_id=? OR compatible_ability_id=?
ORDER BY ability_id,compatible_ability_id`,
)
).all(id, id),
};
}
if (proposal.action === 'saveCombatEffect') {
@@ -174,16 +188,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,16 +321,35 @@ 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.entity_id
? await skillChipProposalSnapshot(
database,
String(proposal.entity_id),
)
: proposal.module === 'calculations'
? await calculationProposalSnapshot(database, proposal)
: proposal.module === 'specializations'
? await specializationProposalSnapshot(database, proposal)
: 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,
String(proposal.entity_id),
)
: proposal.module === 'calculations'
? await calculationProposalSnapshot(database, proposal)
: proposal.module === 'specializations'
? await specializationProposalSnapshot(database, proposal)
: null,
})),
);
const summaryRows = await (
+90 -126
View File
@@ -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,38 +150,29 @@ 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([
databaseLimit
? (
await database.prepare(`SELECT * FROM gear_mods ${where}
const [mods, qualityRows, stats, renders, secondaryPool] = await Promise.all([
databaseLimit
? (
await database.prepare(`SELECT * FROM gear_mods ${where}
ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)
).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',
)
).all(),
(
await database.prepare(
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='gear_mod_render'",
)
).all(),
]);
).all(...args, databaseLimit, databaseOffset)
: [],
(await database.prepare('SELECT * FROM gear_mod_quality_values')).all(),
(
await database.prepare(
'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
"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');
+8 -46
View File
@@ -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 = [
+237 -167
View File
@@ -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,17 +330,20 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
profile.quality === protocol.quality &&
profile.primary_attribute_type === protocol.attributeType,
) ?? null,
talent: {
name: protocol.talentName,
description_template: protocol.descriptionTemplate,
conditionalRule: (() => {
try {
return JSON.parse(String(protocol.conditionalRule ?? 'null'));
} catch {
return null;
}
})(),
},
talent:
protocol.hasTalent === '1'
? {
name: protocol.talentName,
description_template: protocol.descriptionTemplate,
conditionalRule: (() => {
try {
return JSON.parse(String(protocol.conditionalRule ?? 'null'));
} catch {
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) => ({
role,
statId: statByName.get(expected[role].toLowerCase()) ?? '',
value: Number(form.get(`value${role}`)),
}));
const effects = secondaryRoles.map((role) => {
const rawValue = form.get(`value${role}`);
return {
role,
statId: attributeIds[role as keyof typeof attributeIds] ?? '',
value:
typeof rawValue === 'string' && rawValue.trim()
? Number(rawValue)
: Number.NaN,
};
});
if (
effects.some(
(effect) =>
@@ -481,47 +472,58 @@ export const actions: Actions = {
profileError:
'Every shared secondary attribute requires a non-negative value.',
});
const existing = await (
await database.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;
if (existing)
await (
await database.prepare(
'UPDATE os_protocol_secondary_profiles SET updated_at=? WHERE id=?',
const saveProfile = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const existing = await (
await tx.prepare(
'SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?',
)
).run(now, profileId);
else
).get(quality, attributeType);
const profileId = String(existing?.id ?? randomUUID());
const now = new Date().toISOString();
const before = existing ? await profileSnapshot(profileId, tx) : null;
if (existing)
await (
await tx.prepare(
'UPDATE os_protocol_secondary_profiles SET updated_at=? WHERE id=?',
)
).run(now, profileId);
else
await (
await tx.prepare(
'INSERT INTO os_protocol_secondary_profiles(id,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?)',
)
).run(profileId, quality, attributeType, now, now);
await (
await database.prepare(
'INSERT INTO os_protocol_secondary_profiles(id,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?)',
await tx.prepare(
'DELETE FROM os_protocol_secondary_effects WHERE profile_id=?',
)
).run(profileId, quality, attributeType, now, now);
await (
await database.prepare(
'DELETE FROM os_protocol_secondary_effects WHERE profile_id=?',
)
).run(profileId);
for (const effect of effects)
await (
await database.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);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: existing
? 'os_protocol_profile.updated'
: 'os_protocol_profile.created',
entityId: profileId,
before,
after: await profileSnapshot(profileId),
).run(profileId);
for (const effect of effects)
await (
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,
);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: existing
? 'os_protocol_profile.updated'
: 'os_protocol_profile.created',
entityId: profileId,
before,
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,22 +574,27 @@ export const actions: Actions = {
.normalize('NFKC')
.trim();
const database = await getDatabase();
let conditionalRule;
let conditionalRule: ReturnType<typeof parseConditionalRulePayload> | null =
null;
try {
const conditionalRuleRaw = form.get('conditionalRule');
const activeStats = await (
await database.prepare('SELECT id FROM stat_attributes WHERE active=1')
).all();
conditionalRule = parseConditionalRulePayload(
conditionalRuleRaw,
[{ key: 'base', variantKey: 'base' }],
{
allowedAttributeIds: new Set(
activeStats.map((row) => String(row.id)),
),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
},
);
if (hasTalent) {
const conditionalRuleRaw = form.get('conditionalRule');
const activeStats = await (
await database.prepare(
'SELECT id FROM stat_attributes WHERE active=1',
)
).all();
conditionalRule = parseConditionalRulePayload(
conditionalRuleRaw,
[{ key: 'base', variantKey: 'base' }],
{
allowedAttributeIds: new Set(
activeStats.map((row) => String(row.id)),
),
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=?',
`SELECT profile.id FROM os_protocol_secondary_profiles profile
JOIN os_protocol_secondary_effects effect ON effect.profile_id=profile.id
WHERE profile.quality=? AND profile.primary_attribute_type=?
GROUP BY profile.id
HAVING COUNT(effect.id)=3
AND SUM(CASE WHEN effect.effect_role='Core' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1
AND SUM(CASE WHEN effect.effect_role='Damage' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1
AND SUM(CASE WHEN effect.effect_role='Health' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1`,
)
).get(quality, attributeType))
)
).get(
quality,
attributeType,
profileAttributeIds.Core,
profileAttributeIds.Damage,
profileAttributeIds.Health,
));
if (!completeProfile)
return fail(400, {
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,71 +759,83 @@ 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;
if (id && !before?.record)
return fail(404, { protocolError: 'OS Protocol not found.' });
await database.exec('BEGIN');
try {
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.' });
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);
await (
await database.prepare(`INSERT INTO os_protocol_talents(id,protocol_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
ON CONFLICT(protocol_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)
).run(talentId, protocolId, talentName, descriptionTemplate, now, now);
await (
await database.prepare(
'DELETE FROM os_protocol_talent_effects WHERE talent_id=?',
)
).run(talentId);
let compatibilitySortOrder = 0;
for (const outcome of numberedOutcomes) {
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId)
continue;
compatibilitySortOrder += 1;
if (hasTalent && conditionalRule) {
await (
await database.prepare(
'INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) 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 tx.prepare(
'DELETE FROM os_protocol_talent_effects WHERE talent_id=?',
)
).run(
randomUUID(),
).run(talentId);
let compatibilitySortOrder = 0;
for (const outcome of numberedOutcomes) {
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId)
continue;
compatibilitySortOrder += 1;
await (
await tx.prepare(
'INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) VALUES(?,?,?,?,?)',
)
).run(
randomUUID(),
talentId,
outcome.attributeId,
outcome.variants.base.value,
compatibilitySortOrder,
);
}
await saveConditionalRuleInTransaction(
tx,
'os_protocol_talent',
talentId,
outcome.attributeId,
outcome.variants.base.value,
compatibilitySortOrder,
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 saveConditionalRuleInTransaction(
database,
'os_protocol_talent',
talentId,
conditionalRule,
);
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',
+36 -18
View File
@@ -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,21 +186,31 @@
<div class="protocol-primary-editor"><div class="field"><span>PRIMARY STAT</span><SearchableAssignment items={data.stats} fieldName="primaryStatId" mode="single" compact subjectLabel="PRIMARY ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={editing.primaryEffect.stat_attribute_id}/></div><label class="field">LEVEL 1 BASE VALUE<input name="primaryBaseValue" type="number" step="any" min="0" bind:value={editing.primaryEffect.base_value} required/></label><div><span>LEVEL {protocolLevel} PREVIEW</span><b>{format(levelValue(editing.primaryEffect.base_value, protocolLevel), editing.primaryEffect.stat_attribute_id)}</b><small>LEVEL 50 · {format(levelValue(editing.primaryEffect.base_value, 50), editing.primaryEffect.stat_attribute_id)}</small></div></div>
<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="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
attributes={data.stats}
valueColumns={talentValueColumns}
idPrefix={`protocol-talent-${editing.talent.id || 'new'}`}
aiContext={{
sourceKind: 'os_protocol_talent',
sourceName: editing?.talent?.name ?? '',
sourceDescription: editing?.talent?.description_template ?? ''
}}
allowProtocolUpgradeScaling
bind:rule={editing.talent.conditionalRule}
/>
<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
attributes={data.stats}
valueColumns={talentValueColumns}
idPrefix={`protocol-talent-${editing.talent.id || 'new'}`}
aiContext={{
sourceKind: 'os_protocol_talent',
sourceName: editing?.talent?.name ?? '',
sourceDescription: editing?.talent?.description_template ?? ''
}}
allowProtocolUpgradeScaling
bind:rule={editing.talent.conditionalRule}
/>
</div>
</details>
{:else}
<p class="console-note">This OS Protocol has no Talent. Its primary and inherited secondary Attributes remain available.</p>
{#if editing.talent.id}<label class="protocol-talent-removal"><input type="checkbox" name="confirmTalentRemoval" value="REMOVE" required/> CONFIRM REMOVAL OF THE EXISTING TALENT AND MECHANIC PROGRAM</label>{/if}
{/if}
</section>
{#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 }),
}),
);
});
});
+361 -227
View File
@@ -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: {
name: set.talentName,
description_template: set.descriptionTemplate,
conditionalRule: (() => {
try {
return JSON.parse(String(set.conditionalRule ?? 'null'));
} catch {
return null;
}
})(),
},
talent:
String(set.hasTalent) === '1'
? {
name: set.talentName,
description_template: set.descriptionTemplate,
conditionalRule: (() => {
try {
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,25 +510,30 @@ export const actions: Actions = {
.normalize('NFKC')
.trim();
const database = await getDatabase();
let conditionalRule;
let conditionalRule: ReturnType<typeof parseConditionalRulePayload> | null =
null;
try {
const conditionalRuleRaw = form.get('conditionalRule');
const activeStats = await (
await database.prepare('SELECT id FROM stat_attributes WHERE active=1')
).all();
conditionalRule = parseConditionalRulePayload(
conditionalRuleRaw,
[
{ key: 'minimum', variantKey: 'minimum' },
{ key: 'maximum', variantKey: 'maximum' },
],
{
allowedAttributeIds: new Set(
activeStats.map((row) => String(row.id)),
),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
},
);
if (hasTalent) {
const conditionalRuleRaw = form.get('conditionalRule');
const activeStats = await (
await database.prepare(
'SELECT id FROM stat_attributes WHERE active=1',
)
).all();
conditionalRule = parseConditionalRulePayload(
conditionalRuleRaw,
[
{ key: 'minimum', variantKey: 'minimum' },
{ key: 'maximum', variantKey: 'maximum' },
],
{
allowedAttributeIds: new Set(
activeStats.map((row) => String(row.id)),
),
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 ||
talentName.length > 80 ||
!descriptionTemplate ||
!conditionalRule.clauses.length ||
!outcomes.length
hasTalent &&
(talentName.length < 2 ||
talentName.length > 80 ||
!descriptionTemplate ||
!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,61 +725,50 @@ export const actions: Actions = {
await database.prepare(`INSERT INTO skill_chip_two_piece_bonuses(id,skill_chip_set_id,stat_attribute_id,min_value,max_value) VALUES(?,?,?,?,?)
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)
if (hasTalent && conditionalRule) {
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);
await (
await database.prepare(`INSERT INTO skill_chip_talents(id,skill_chip_set_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
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`)
).run(talentId, setId, talentName, descriptionTemplate, now, now);
await (
await database.prepare(
'DELETE FROM skill_chip_talent_effects WHERE talent_id=?',
)
).run(talentId);
let compatibilitySortOrder = 0;
for (const outcome of numberedOutcomes) {
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId)
continue;
compatibilitySortOrder += 1;
).run(talentId, setId, talentName, descriptionTemplate, now, now);
await (
await database.prepare(
'INSERT INTO skill_chip_talent_effects(id,talent_id,stat_attribute_id,min_value,max_value,sort_order) VALUES(?,?,?,?,?,?)',
'DELETE FROM skill_chip_talent_effects WHERE talent_id=?',
)
).run(
randomUUID(),
).run(talentId);
let compatibilitySortOrder = 0;
for (const outcome of numberedOutcomes) {
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId)
continue;
compatibilitySortOrder += 1;
await (
await database.prepare(
'INSERT INTO skill_chip_talent_effects(id,talent_id,stat_attribute_id,min_value,max_value,sort_order) VALUES(?,?,?,?,?,?)',
)
).run(
randomUUID(),
talentId,
outcome.attributeId,
outcome.variants.minimum.value,
outcome.variants.maximum.value,
compatibilitySortOrder,
);
}
await saveConditionalRuleInTransaction(
database,
'skill_chip_talent',
talentId,
outcome.attributeId,
outcome.variants.minimum.value,
outcome.variants.maximum.value,
compatibilitySortOrder,
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 saveConditionalRuleInTransaction(
database,
'skill_chip_talent',
talentId,
conditionalRule,
);
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,33 +807,163 @@ 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 (
await database.prepare(
'UPDATE skill_chip_sets SET active=0,updated_at=? WHERE id=?',
)
).run(new Date().toISOString(), id);
await refreshAbilityFocuses(
database,
String(before.record.specialization_ability_id),
);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'skill_chip.archived',
entityId: id,
before,
after: await snapshot(id),
});
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=?',
)
).run(new Date().toISOString(), id);
await refreshAbilityFocuses(
database,
String(before.record.specialization_ability_id),
);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'skill_chip.archived',
entityId: id,
before,
after: await snapshot(id),
database,
});
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return { skillChipArchived: true };
},
unarchive: async ({ request, cookies }) => {
@@ -851,50 +972,63 @@ 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();
if (
!(await (
await database.prepare(
'SELECT id FROM specialization_abilities WHERE id=? AND active=1',
)
).get(abilityId))
) {
return fail(400, {
skillChipError:
'The assigned specialization ability must be active before restoring this Skill Chip.',
});
}
const assignedCount = Number(
(
await (
await database.prepare(`SELECT COUNT(*) AS count FROM skill_chip_sets
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(
'SELECT id FROM specialization_abilities WHERE id=? AND active=1',
)
).get(abilityId))
) {
await database.exec('ROLLBACK');
return fail(400, {
skillChipError:
'The assigned specialization ability must be active before restoring this Skill Chip.',
});
}
const assignedCount = Number(
(
await (
await database.prepare(`SELECT COUNT(*) AS count FROM skill_chip_sets
WHERE specialization_ability_id=? AND active=1 AND id<>?`)
).get(abilityId, id)
)?.count ?? 0,
);
if (assignedCount >= 3)
return fail(409, {
skillChipError:
'That ability already has all three active Skill Chips assigned.',
).get(abilityId, id)
)?.count ?? 0,
);
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=?',
)
).run(new Date().toISOString(), id);
await refreshAbilityFocuses(database, abilityId);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'skill_chip.unarchived',
entityId: id,
before,
after: await snapshot(id),
database,
});
await (
await database.prepare(
'UPDATE skill_chip_sets SET active=1,updated_at=? WHERE id=?',
)
).run(new Date().toISOString(), id);
await refreshAbilityFocuses(database, abilityId);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'skill_chip.unarchived',
entityId: id,
before,
after: await snapshot(id),
});
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return { skillChipUnarchived: true };
},
};
+36 -19
View File
@@ -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,10 +120,11 @@
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY CHIP SET' : 'NEW CHIP SET'}</p><h3>{editing.id ? editing.name : 'SKILL CHIP DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
<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>
<div class="selection-head"><div><span>OWNING ABILITY</span><small>THREE ACTIVE DEFINITIONS · ABILITY SHARING IS CONFIGURED IN SPECIALIZATIONS</small></div><b>{editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 OWNED` : 'REQUIRED'}</b></div>
<SearchableAssignment items={data.abilities} fieldName="abilityId" mode="single" compact subjectLabel="SPECIALIZATION ABILITY" searchPlaceholder="Search abilities or specializations…" emptySelectionLabel="NO ABILITY ASSIGNED" bind:value={editing.abilityId}/>
</section>
@@ -118,15 +133,14 @@
<div class="skill-chip-bonus-grid"><div><SearchableAssignment items={data.stats} fieldName="twoPieceStatId" mode="single" subjectLabel="BONUS ATTRIBUTE" searchPlaceholder="Search attributes…" emptySelectionLabel="NO BONUS ATTRIBUTE SELECTED" bind:value={editing.twoPiece.stat_attribute_id}/></div><div class="skill-chip-range-fields"><label class="field">MINIMUM VALUE<input name="twoPieceMin" type="number" step="any" bind:value={editing.twoPiece.min_value} required/></label><label class="field">MAXIMUM VALUE<input name="twoPieceMax" type="number" step="any" bind:value={editing.twoPiece.max_value} required/></label></div></div>
</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>OWNING ABILITY</small></div><span class="skill-chip-quality-pair"><b>STD</b><b>SP</b><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT ASSIGNED'}</strong><small>{item.talent ? item.talent.conditionalRule?.clauses?.length || item.pending ? 'MODELED · QUALITY SCALED' : 'CONFIG REQUIRED' : 'OPTIONAL'}</small></div><span>SHARED · 4 PRIMARY · {data.secondaryPool.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
{#if editing?.id === item.id}<RegistryInlineEditor id={`skill-chip-editor-${item.id}`} label={`Edit ${item.name}`}>{@render skillChipEditor()}</RegistryInlineEditor>{/if}
{: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();
});
});

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